Skip to content

Add References, every reference between packs that pks lists - #61

Merged
dduugg merged 24 commits into
mainfrom
references
Oct 14, 2026
Merged

dduugg merged 24 commits into
mainfrom
references

Conversation

@dduugg

@dduugg dduugg commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds References: every reference from one pack to a constant that another pack defines, whether or not it breaks the packs' rules, as pks list-references lists them. It has the same methods as Violations, so query_packwerk can show how a pack is used where the rules allow it, which package_todo.yml files don't record:

References.where(producing_pack: "packs/foo", allowed: true).anonymous_source_counts
  • QueryPackwerk.references_for(pack) and references_from(pack), and Package#incoming_references and #outgoing_references, get the references to or from a pack, as violations_for and todos_for do for violations. They, and violations_for, package and the rest, take the name of any pack that ParsePackwerk finds, as ., rather than putting packs/ before it, or a pack's short name in packs/.
  • where can query what it can for a violation (the constant, packs and owners), along with file, allowed, violation_types, and unrecorded and unrecorded_violation_types for the violations that no package_todo.yml records, which pks check reports. It reads only the keys it's asked for, a block narrows what they find, rather than taking their place, and a constant named without its leading ::, as Foo::Bar, finds ::Foo::Bar.
  • reload! forgets the references too, so the next query runs pks again, and it makes ParsePackwerk read the packs and their package_todo.yml files again, which it didn't before. It returns nil, which the console shows plainly, rather than Sorbet's void.
  • help_reference, help_references and the README describe them.

Fixes #8.

pks

  • query_packwerk runs bin/pks if the app has one, as an app that pins pks's version does, or else pks on the PATH. It checks for 0.7.0 or later, the first version with list-references, each time it runs pks, and when pks is missing or older, the error links to pks's installation instructions. A bin/pks that isn't executable, as a DotSlash file saved from a release isn't at first, gets an error that says to chmod +x it, rather than pks on the PATH running instead. pks's warnings, as about a file it can't parse, go to stderr.
  • pks's output is read as UTF-8, whatever the locale.
  • pks follows a symlink to a pack, where ParsePackwerk's default **/ glob doesn't, so a pack that ParsePackwerk doesn't find is read from its package.yml.
  • Violations don't use pks, so nothing changes for an app without it.

Finding sources

  • pks says where each reference is, so a reference's source is the code at that line and column, rather than wherever the file names the constant. That finds a constant that the code names relatively, as Bar inside module Foo, which matching names misses. A reference that pks infers from an association, as has_many :tacos is for ::Taco, gets the call as its source (Say where an association's reference is, and split out list-references' violations pks#80 documents both).
  • pks counts columns in bytes and the parser counts characters, so FileCache#get_full_source_at converts them.
  • In ERB, pks gives no position and lists a template's references to one constant once, so their sources are found by name, as a violation's are (Find violations in ERB templates #46).
  • A source extends only through calls on the constant itself, so in Foo.bar(Foo), the second Foo's source isn't the whole call. Finding a constant by name skips the Foo in Foo::Bar, which neither packwerk nor pks counts as a use of ::Foo. Both change what violations find too, in those cases.

Commits

  • 70d82e9 moves what Violation and Violations share into two abstract classes, ConstantUse and ConstantUses, which Reference and References also subclass. A subclass says only how to find its sources in a file. Nothing changes for callers, and git show --color-moved 70d82e9 shows the move. FileCache#get_full_anonymous_sources lost its only caller and is gone. sorbet/rbi/todo.rbi changes by one line, because regenerating it would also turn ActionView::ActionViewError back into a module, which Sorbet rejects.
  • 1ae3626 fixes where on an empty collection, which raised NoMethodError on nil. violations_for did that in an app whose package_todo.yml files record nothing, and references_for would in an app with no references between packs.
  • 5771a3d adds References.
  • 3db056d keeps a source to the code that uses its constant: the receiver chain, a constant that's only a namespace, and a file that can't be read, which gave a reference at 1:0 the source x.
  • 84f91de anonymizes a call with a heredoc argument, whose source doesn't parse without the heredoc's body, without raising. That raised for the whole collection, violations included.
  • 12f66f2 makes reload! bust ParsePackwerk's cache, and checks pks's version each time it runs.
  • 5efecb8 runs pks and reads what it lists in more of the ways apps have it, as above.
  • cadeeed gives each query all's file cache, so a file is parsed once until reload!, for violations too.
  • d04a81b lets violations_for, references_for and the rest take the name of any pack.
  • 6edfbfa corrects the help text, the README and some comments.
  • 12d14f1 checks more of what a reference finds where pks lists it.
  • 0f28b6b lets count take a block or an item, as Enumerable's does, and where take a block along with its keys. Both ignored them before.
  • a6d46fc gives is_active_record false, rather than nil, for a class that isn't a model.
  • 79ce924 types sources_with_locations and the other source methods as what they return.
  • 132d72f reads only the keys that where asks for, so in a Rails app it no longer looks up each reference's constant.
  • 703eabb adds the unrecorded and unrecorded_violation_types keys.
  • 82ee789 prints a progress dot for each file parsed, rather than for each violation or reference too.
  • a2258cc installs pks 0.7.0 in CI, with bin/install-pks, which checks its sha256, so the check of the specs' listing against pks runs there.
  • 8c065bc gives a violation's owned true when its producing pack has an owner. It compared the owner with nil, but Package#owner gives 'Unowned' rather than nil, so it was always false.
  • eeb73a0 finds each pack that pks names through one small lambda in References.all.
  • 62b8abc lets where find a constant named without its leading ::, for violations too.
  • 542f3fe lets package take a pack's short name, as violations_for does.
  • 45f03b2 returns nil from reload!, so the console prints # => nil rather than # => T::Private::Types::Void::VOID.
  • 3749b94 reads pks's version without requiring its line to end after it, so a build that prints more, as pks 0.7.0 (abc1234), passes the check.

Design notes

  • References.all runs pks once and keeps what it lists, as Violations.all keeps its violations. references_for filters that, rather than running pks with --defining-pack, since pks reads every file either way. references_from filters it too. pks with --referencing-pack reads only that pack's files, but a run for one pack couldn't answer the next query.
  • A query's result shares all's file cache, so the files it parses stay in memory until reload!, about 200 KB for a file of 100 lines. The README says so.
  • pks lists a reference once for each pack that defines its constant, so such a reference's source shows up once per pack, as a violation's shows up once per violation type.
  • owned is true when the producing pack has an owner, for references and, since 8c065bc, violations too.
  • This doesn't bump the version, so merging it doesn't publish the gem.

Test plan

  • spec/support/referenced_packs.rb writes an app with an allowed reference, recorded and unrecorded violations, a relatively named constant, an association, an ERB template, a constant after multibyte characters, and a reference to a pack without an owner, along with what pks 0.7.0 lists for it. The specs use that listing in place of pks. With pks 0.7.0 on the PATH, as CI has it from bin/install-pks, pks_spec.rb checks the listing against pks. Without pks, it skips that check, except in CI, where it fails.
  • Fake pks scripts cover bin/pks taking precedence over the PATH, a bin/pks that isn't executable, a missing pks, an old pks, a version that can't be read, a version that changes between runs, a failing run, passing on warnings, and output in a locale that isn't UTF-8.
  • reference_spec.rb and references_spec.rb cover each kind of reference's sources, where, the aggregate methods, reload!, a pack that has no package.yml, and a pack that a symlink points to.
  • On a large Rails monolith, with pks's cache off:
    • ParsePackwerk found every pack that pks named.
    • A source was found for each of 5,000 sampled references with positions, and each of 200 in ERB.
    • References.all took a few seconds longer than pks did.
    • For a pack that thousands of files reference, sources take a while, almost all of it RuboCop parsing those files. The README says so.
  • On a synthetic app with 60,931 references, a console session of six queries took 30 seconds and peaked at 1.8 GB of memory, where it took 94 seconds and 4.6 GB at 5771a3d. Each reference's sources are the same, and each violation's are, apart from the Foo in Foo::Bar. A query of the references to one pack takes 0.12 seconds, where it took 0.29.
  • bundle exec rspec in random order on Ruby 3.3, 3.4 and 4.0 (170 examples, with the pks check pending when pks isn't installed, and passing with pks 0.7.0), rubocop, and srb tc.

References will show their sources the way violations do, so the methods
that do it move out of Violation and Violations into two abstract
classes they now subclass: ConstantUse, for one pack's use of a constant
another pack defines, and ConstantUses, for a collection of them. A
ConstantUse leaves only one thing to its subclass, how to find its
sources in a file. For a Violation that's still wherever the file names
its constant, since package_todo.yml records no positions.

Nothing changes for callers. Violation#anonymous_sources anonymizes each
source itself, rather than through FileCache#get_full_anonymous_sources,
which had no other caller and is gone. Violation#count counts the
sources the other methods show, which are the constants it counted
before. including_files and excluding_files return the class they're
called on.

sorbet/rbi/todo.rbi stubs ApplicationRecord in ConstantUse, where
runtime_keys now names it. Regenerating the file with tapioca would also
turn ActionView::ActionViewError back into a module, which Sorbet
rejects, so only that line changes.
where checks its keys against the first value in the collection, and
with no values it raised NoMethodError on nil, so in an app whose
package_todo.yml files record no violations, Violations.where and
violations_for raised instead of finding nothing. It now finds nothing,
without checking the keys, since nothing could match them.
package_todo.yml files record only the references that break a pack's
rules, so query_packwerk couldn't show how other packs use a pack where
a declared dependency or a public constant allows it (#8). pks 0.7.0
lists every reference from one pack to another pack's constants, with
the violations each makes. References runs `pks list-references` and
has the same methods as Violations:

    References.where(producing_pack: "packs/foo", allowed: true).anonymous_source_counts

QueryPackwerk.references_for and references_from, and Package's
incoming_references and outgoing_references, get the references to or
from a pack, as violations_for and todos_for do for violations. where
can query a reference's constant, file, packs and owners, as for a
violation, and also allowed and violation_types. reload! forgets the
references too, so the next query runs pks again.

query_packwerk runs bin/pks if the app has one, as an app that pins
pks's version does, or else pks on the PATH. It checks that the version
has list-references, and when pks is missing or too old, the error says
where to get it. Violations still come from package_todo.yml files and
don't need pks.

pks says where each reference is, so a reference finds its source at
that line and column rather than by its constant's name. That finds a
constant that the code names relatively, as `Bar` inside `module Foo`,
which matching names misses, and makes the association call the source
of a reference that pks infers from one, as `has_many :tacos` is for
`::Taco`. pks counts columns in bytes, where the parser counts
characters, so the file cache converts them. pks gives no positions in
ERB, and lists a template's references to a constant once, so there the
sources are wherever the template names the constant, as for
violations.

spec/support/referenced_packs.rb writes an app with each kind of
reference, along with what pks 0.7.0 lists for it, which the specs use
in place of pks. spec/query_packwerk/pks_spec.rb checks that listing
against pks when pks is installed, and skips the check when it isn't.
@dduugg
dduugg requested a review from a team as a code owner October 13, 2026 23:37
dduugg added 21 commits October 13, 2026 17:39
A source is a constant with the calls chained on it, and finding those
calls climbed through any parent whose receiver equaled the node. That
compared nodes by structure, and blocks and singleton method
definitions have receivers too, so in `Foo.bar(Foo)` the second `Foo`'s
source was the whole call, in `Foo.each { Foo }` the inner one's was
the whole block, and in `def Foo.patched` it was the whole method. Now
only a call on the node itself extends it. Violations share the code,
so they counted such a call twice, once for each `Foo`, and now don't.

Finding a constant by name also matched the `Foo` in `Foo::Bar`, which
neither packwerk nor pks counts as a use of `::Foo`, since both resolve
`Foo::Bar` by its whole name. So a template that names `Foo::Bar` gave
a reference to `::Foo` an extra source, as any file did a violation of
`::Foo`.

A file that FileCache can't read or parse stood in as `x`, which is a
call, so a reference at line 1, column 0 of such a file got `x` as its
source. It stands in as `nil` now, which has no constant or call.
A source is the code of its node, and a heredoc's body isn't in that
code, since it's on the lines after the call, as in

    Foo.execute(1, <<~SQL)
      SELECT 1
    SQL

So `Foo.execute(1, <<~SQL)` doesn't parse, and anonymizing it raised
NoMethodError on the nil AST, which failed anonymous_sources,
anonymous_source_counts, anonymous_sources_with_locations and
sources_with_contexts for every violation or reference in a
collection. That was so before References, but References include
every use of a pack's constants, so one with a heredoc is likelier.
Such a source now keeps its arguments as they are.
ParsePackwerk keeps the packs it reads, with the violations in their
package_todo.yml files, and reload! didn't make it forget them. So
after a change to a package.yml or package_todo.yml file, reload!
still gave the violations from before, though the README says it
picks up such changes. With References that got worse: pks lists the
packs as they are now, so a pack added or renamed since the packs
were read made every query of references raise Pks::Error until the
console restarted. reload! busts ParsePackwerk's cache now too.

pks's version was checked once for each path, so after checking out
a branch whose bin/pks is older, the next query failed on
`list-references` itself rather than saying to upgrade. The version
is checked each time pks runs now, which is once until the next
reload!.
A DotSlash file saved from a pks release, which is how an app pins
pks's version in bin/pks, isn't executable until it's made so, and
query_packwerk then ran pks on the PATH instead, or said there was no
bin/pks. It says to `chmod +x bin/pks` now.

The version check read pks's stdout and stderr together and expected
the version at the start, so a wrapper that warns first failed it. It
reads only stdout now.

pks prints UTF-8, but Open3 gives what it prints in the locale's
encoding, so with LANG=C, a file or constant with a character outside
ASCII made JSON.parse raise Encoding::InvalidByteSequenceError. The
output is read as UTF-8 now. Its strings are also frozen, so each one
that repeats, as a file's name does for each of its references, is
kept once.

pks follows a symlink to a pack, but ParsePackwerk's default `**/`
glob doesn't, so a symlinked pack made every query of references raise
Pks::Error. A pack that ParsePackwerk doesn't find is read from its
package.yml now, and only a pack without one raises.

The fake pks in the specs prints its JSON with printf, since echo in
dash, Ubuntu's /bin/sh, reads the backslashes in it as escapes.
A query, as with where, including_files or excluding_files, built its
result with a new FileCache, and pointed the violations or references
in it, which all keeps too, at that cache. So asking again, or asking
about some of the same code, parsed every file again, and the earlier
results' caches stayed in memory. References made that worse, since a
pack's references are in more files than its violations are, and each
reference is in two packs' results. A result shares all's cache now,
so a file is parsed once until reload!, and setting a use's cache to
the one it has leaves it loaded.

The progress line counted a file once for each use in it, and printed
a dot for each, though the file is parsed once. It counts, and dots,
each file once now.

Finding the node at pks's line and column converted the column of
every node on the line from characters to bytes, which on a line with
thousands of references, as in a generated list of constants, took
minutes. pks's column is converted to characters once instead.
full_name puts packs/ before any name that doesn't start with packs/ or
components/, so violations_for, todos_for, references_for,
references_from and the methods that use them found nothing for the
root pack, ".", or for a pack elsewhere, as engines/wax. The root pack
is often where references come from, as for code in app/. A name that
ParsePackwerk finds is kept as it is now.
help_violations and help_references, which copied it, said that
sources_with_contexts and anonymous_sources_with_locations return
arrays of [file:line, source] pairs, but they return hashes of each
constant's anonymized sources, and showed consumers and producers
taking a threshold that is a keyword. help_reference lists column,
source_counts and anonymous_sources_with_locations too now, and
help_references lists raw_sources, as for violations.

The README says that reload! is for changes to code too, since a
reference's position is where pks found it, and that the parsed files
stay in memory until then. A file that doesn't parse is said to have
no sources, rather than no violations, since references have them
too, and the comments on Reference and ConstantUses say what they are
more exactly.
A reference finds its sources where pks says it is, but only sources,
sources_with_locations and anonymous_sources were checked that way,
and on references whose files name their constants once. So count,
source_counts, anonymous_sources_with_locations and
sources_with_contexts could have found sources by name and passed.
They're checked on apiary_report.rb:2 now, whose file names its
constant again on line 4, as are its two violations, one that a
package_todo.yml records and one that it doesn't.

Every reference in the specs' app was to packs/honey_production, so
references_for and incoming_references were checked against the count
of all references, and producers and owned: false weren't checked. The
app has a reference to a pack without an owner now, as pks 0.7.0 lists
it. The specs also check a constant with no call on it, as an argument
or a superclass, a strict pack, and excluding_files.
count was an alias of size, so it ignored a block or an item, as in
`References.all.count(&:allowed?)`, and gave how many references
there are in all. It counts what they match now, as Enumerable does.

where gave its block the place of its keys, so
`References.where(allowed: true) { |reference| ... }` found what the
block matched, allowed or not. The block narrows what the keys find
now.
In a Rails app, is_active_record was `constant < ApplicationRecord`
for a class, and Class#< gives nil, not false, for a class that isn't
related to ApplicationRecord. So `where(is_active_record: false)`
found modules and constants that aren't loaded, but no classes. It's
false for such a class now.
sources_with_locations gives [file:line, source] pairs, but its sig
said an array of strings, or of strings and arrays of nodes, and
violation_sources_for's said the same. So Sorbet let a caller treat
each pair as a string, which raised at runtime, since sorbet-runtime
doesn't check what's in an array. anonymous_sources_with_locations,
sources_with_contexts and raw_sources returned T.untyped. Each says
what it returns now.
where asks each violation or reference in a collection for the keys
it queries, and deconstruct_keys built all of them before slicing out
those, so a query of one key cost what a query of all of them did. In
a Rails app, that included looking up every reference's constant, for
is_active_record and is_constant, though no query asked about them,
which can load the constant. Violation and Reference each keep a table
of how to read a key now, and ConstantUse's deconstruct_keys reads
only the keys asked for, looking constants up only for those two.

On an app with 61,000 references, a query of the references to one
pack took 0.29 seconds and takes 0.12 now. A query of violations took
0.13 seconds and takes 0.08.
A reference's violations say whether a package_todo.yml records each
one, but where couldn't ask, so finding what `pks check` reports took
a block. `where(unrecorded: true)` finds the references with such a
violation now, and `where(unrecorded_violation_types: 'privacy')` those
with one of a type, from Reference#unrecorded_violation_types:

    References.where(producing_pack: "packs/foo", unrecorded: true).anonymous_source_counts

A violation's strictness is left out, as it's how the pack is set up,
and a recorded violation of a strict rule isn't what `pks check`
reports.
Loading a collection's sources printed a dot for each violation or
reference in it, after the dots for the files it parsed, and did it
for every new query, though the sources load quickly once the files
are parsed. For a pack with 24,000 references, that was 24,000 dots
each time, even when nothing was parsed. Only the files parsed get a
dot now, so a repeated query prints a few lines.
Every spec of references uses what spec/support/referenced_packs.rb
says pks lists for its app, and pks_spec.rb checks that against pks,
but only where pks is installed, which CI wasn't. So a change to the
app or to that listing that pks wouldn't agree with passed in CI.

bin/install-pks downloads pks 0.7.0, the oldest version that
query_packwerk supports, into tmp/pks, and checks the archive's sha256
before unpacking it. CI runs it before the specs and puts tmp/pks on
the PATH, and the check fails in CI rather than skipping when pks
isn't there. It covers Linux on x86_64, as in CI, and macOS on Apple
Silicon.
A violation's owned compared its producing pack's owner with nil, but
Package#owner gives 'Unowned' rather than nil for a pack without one,
so owned was always false: `where(owned: true)` found no violations,
and `where(owned: false)` found them all. It compares the owner with
Packages::UNOWNED now, as Package's and Reference's owned do.
References.all kept each pack it found in a hash, so a pack is looked
up once however many references name it, but each of the two keyword
arguments did that inline, naming its key twice in a line of more than
100 characters. A lambda does it now, so each argument reads as the
pack it is.
package_todo.yml files and pks name each constant from the top, as
::Foo::Bar, so `where(constant_name: 'Foo::Bar')` found nothing, though
that's how code names it. Violations and References qualify a constant
name that where is given, alone or in an array, before they match it,
and leave a pattern such as a Regexp as it is.
violations_for, references_for and the rest take a name in packs/ as
honey_production, through full_name, but package didn't, so
`package("honey_production")`, as the README writes it, gave nil, and
`package("honey_production").incoming_references` raised
NoMethodError. package goes through full_name too now.
reload! and each collection's reload! had `void` sigs, and
sorbet-runtime makes a void method return
T::Private::Types::Void::VOID, so after `reload!` the console printed
`# => T::Private::Types::Void::VOID`. They return nil now, as the
console's help methods do, so it prints `# => nil`.
5efecb8 read the version from a line of pks's stdout that is only
`pks <version>`, so a build that prints more after it, as
`pks 0.7.0 (abc1234)`, would fail the check, though it's new enough.
The version is the word after `pks ` at the start of a line now, as
before 5efecb8, which anchored it at the start of the output.
@dduugg
dduugg merged commit 750df3b into main Oct 14, 2026
11 checks passed
@dduugg
dduugg deleted the references branch October 14, 2026 17:01
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.

Violations vs References

1 participant