Skip to content

Follow up #82: look up associations as Rails does, name constants through Object, and match packwerk on :: names and class_eval - #83

Merged
dduugg merged 9 commits into
mainfrom
top-level-follow-ups
Oct 14, 2026
Merged

dduugg merged 9 commits into
mainfrom
top-level-follow-ups

Conversation

@dduugg

@dduugg dduugg commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-ups to #82, found while reviewing it, and the gaps they turned up.

With --experimental-parser, an association is looked up as Rails looks it up.

  • Rails looks up the class of has_many :tacos from the name of the model that declares it (ActiveRecord's compute_type): in Outer::Foo, as Outer::Foo::Taco, Outer::Taco, then Taco, taking only a class of that very name. pks gave the reference the namespaces it's written in, so in class Outer::Foo it skipped Outer::Taco and found Taco, and in module Outer; class ::Foo it found Outer::Taco, which Rails never tries.
  • When the class has the model's own last name, as belongs_to :company in Payroll::Company, Rails tries ::Company first, then the model's name (_klass in reflection.rb), and so does pks.
  • In a module, as a concern's included block, Rails looks up the class from the name of each model that includes the module. pks records the include and prepend calls in each class and module, and looks up the class from each class that includes the module, directly or through other modules. Where none does, the module's name stands in.
  • In a controller, inherited_resources looks up belongs_to :taco from the controller's namespaces, and a class_name: from the top level, and so does pks.

With --experimental-parser, a constant written through Object is named as Ruby names it. class Object::Bar inside module CoreExt defines ::Bar, and so does class Bar inside a top-level class Object. pks named them ::CoreExt::Object::Bar and ::Object::Bar, and now names them ::Bar, unless the app has a CoreExt::Object, even an empty one, in which case it's ::CoreExt::Object::Bar, as in Ruby. A reference written ::Object::Bar now resolves to ::Bar, and inside module CoreExt the resolver tries ::CoreExt::Object::Bar for Object::Bar before ::Bar. A class Object inside a namespace is still the namespace's own.

With --experimental-parser, the constants of Kernel and BasicObject are found from anywhere, after the top level's own, as in Ruby: class BasicObject::Foo is found as Foo.

Without --experimental-parser, pks matches packwerk around a name written from the top level, and in class_eval and Class.new blocks. Checked against packwerk 3.3.1 and constant_resolver 0.3.0, run directly in Ruby:

  • module Outer; class ::Burrito: both name the class ::Outer::::Burrito. constant_resolver takes the parent of Outer::::Burrito, Outer::, with Ruby's String#split, which drops the empty name at the end, so Outer:: has no parent and it finds nothing (constant_resolver.rb:163). Rust's split keeps it, so pks went on to Outer and reported the class against Outer's pack. In module Aa; module Bb; class ::Cee, the same split takes packwerk from Aa::Bb:: to Aa, where pks stopped at Aa::Bb. The Zeitwerk resolver now drops the empty names at the end too.
  • class ::Taco (or ::Taco = 1) at the top level of a file, then Taco.make: packwerk's own definitions for the file have ::::Taco, so the later Taco, looked for as ::Taco, isn't local, and packwerk reports it. pks also looked for :: plus the name, found ::::Taco, and dropped it. The packwerk parser now looks for the name alone. The same goes for a Taco written inside class ::Taco, for constants defined in that class (::::Taco::Shell), and for the ::::Taco pks takes from class ::Taco::Shell; each is now a reference, as in packwerk.
  • packwerk names the reference a class makes to itself from every class, module, Foo.class_eval do block and Foo = Class.new do around it (parent_module_name), so module Aa; Bb.class_eval do; class ::Cee is ::Aa::Bb::::Cee, a reference to Aa. pks left out Bb, which the split change above turned into a regression: it found no reference there. The packwerk parser now keeps the same names for this, including packwerk's quirks (blocks taking _1 or it add nothing; it names no class in a block passed with &. or to super).

The CHANGELOG has an entry for each. Each can move or drop a violation, so a package_todo.yml entry can go stale until pks update.

How the experimental changes work

The experimental parser now leaves an association in a class or module, and the include/prepend calls, in new ProcessedFile fields (cached, empty ones left out of the cache file), and process_files in experimental.rs turns them into references once every file is read and the resolver is built. A file that defines a constant through Object inside a namespace is flagged, and when the app has some namespace's own Object, those files are read again knowing which. check-contents reads its file from stdin, so stdin's contents are now kept after the first read.

Test plan

  • cargo test (398 passed), cargo fmt --check, and cargo clippy --all-targets --all-features -- -Dwarnings.
  • New tests, each failing with its own fix reverted:
    • resolve_a_name_from_the_top_level_in_a_namespace (Zeitwerk resolver), test_list_references_to_a_class_defined_from_the_top_level_in_a_namespace, test_list_references_after_a_class_defined_from_the_top_level and test_list_references_after_a_constant_assigned_from_the_top_level: the ::Burrito, ::Cee, class ::Taco and ::Taco = 1 cases, from the file to the references listed.
    • class_defined_in_class_eval, classes_defined_in_blocks_and_assignments (36 cases, each checked against packwerk) and test_list_references_to_a_class_defined_in_class_eval_or_class_new.
    • test_list_references_from_associations_by_the_models_name, ..._in_namespaced_models, ..._in_concerns (two includers, a concern included through another, an unused one, ERB) and ..._named_for_their_model (_klass with and without ::Taco, inherited_resources with and without class_name:, a model whose name ends in Controller).
    • definitions_in_object, resolve_through_object, test_list_references_to_constants_defined_through_object and ..._through_a_namespaces_object (a namespace's own Object in another file, and an empty one), and test_check_contents_of_a_file_read_twice_with_experimental_parser.
    • test_list_references_to_constants_of_objects_ancestors, and unit tests for objects_in_namespaces and models_including.
  • Mutation check: each fix, each defined_name call site, the while in nest_constant_name, and each branch of the association lookup and the class_eval naming, reverted alone, fails at least one of these tests.
  • On copies of simple_app, packwerk 3.3.1 and this build give the same references for 66 files of class ::Foo, ::Foo = 1, class_eval and Class.new cases, except one older difference listed below. The build from main differed on 57 lines.
  • With ActiveRecord 8.1.4: compute_type and _klass as above, including Kitchen::Taco::Taco with no ::Taco; a concern in Plain and in Billing::Invoice, and one included through another concern, give Taco, Billing::Taco and Billing::Taco. inherited_resources 2.1.0, run under Ruby 3.4.11, looks up belongs_to as described.
  • Ruby 4.0.5 names class Object::Bar in module CoreExt Bar, class Object; class Qux; end; Y = 1 Qux and ::Y, class Object::Charge in a module Payments with its own Object Payments::Object::Charge, and finds BasicObject::Foo and Kernel::FOO from any module, after the top level's own.

Not covered

  • An include not written as include/prepend in a class or module body (Model.include(M), send(:include, M), Foo.class_eval { include M }, ActiveSupport.on_load), and an association in a Foo.class_eval block: pks doesn't see the model, and the module's name, or none, stands in.
  • Ruby also looks a constant up in a class's superclasses; pks doesn't know them.
  • A reference written Object::Bar where the namespace's own Object has no Bar: Ruby raises a NameError, and pks goes on to ::Bar, as it does for any name written with a namespace.
  • class A::B inside module M, where A is top-level, is ::A::B in Ruby; pks names it ::M::A::B. Telling them apart needs the namespaces Zeitwerk makes from directories, which pks doesn't read in this mode. The Object case above is the one it can tell.
  • Without --experimental-parser, packwerk counts the scope Kk of Kk::Ll = 1 as a reference, and reads self::Foo as ::Foo; pks doesn't, as before this PR.

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.
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.
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.
…ental-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.
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.
@dduugg
dduugg requested a review from a team as a code owner October 13, 2026 23:20
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.
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.
…ntal-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.
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.
@dduugg dduugg changed the title Follow up #82: look up associations by model name, name constants through Object, and match packwerk on :: names Follow up #82: look up associations as Rails does, name constants through Object, and match packwerk on :: names and class_eval Oct 14, 2026
@dduugg
dduugg merged commit dee5a02 into main Oct 14, 2026
21 checks passed
@dduugg
dduugg deleted the top-level-follow-ups branch October 14, 2026 17:02
@github-project-automation github-project-automation Bot moved this from Triage to Done in Modularity Oct 14, 2026
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