Repository navigation
feat(pipeline): wire Ruby cross-file LSP resolution - #1712
benrfairless wants to merge 17 commits into
Conversation
Adds the cross-file tier on top of the per-file resolver. cbm_run_ruby_lsp_cross builds its class table from the project-wide CBMLSPDef[], deriving each constant path by stripping the def's module QN prefix, and registers methods on both the instance and singleton receiver keys because CBMLSPDef does not carry the `def self.m` split. Ruby is registered on the fallback cbm_pxc_run_one path, like Java and Kotlin — there is no prebuilt tier-2 registry yet. Ruby also joins Rust in bypassing the own+imports module-def filter, for a different reason: Ruby constants live in one global namespace and Rails/Zeitwerk autoloads them with no require statement, so a filter keyed on requires would starve essentially all cross-file resolution in a real Ruby app. That exemption is the one deliberate design decision here. The two new rows drive cbm_run_ruby_lsp_cross with a hand-built CBMLSPDef[] in the shape pass_lsp_cross.c passes it, the second mirroring the Rails-shaped e2e fixture (User < ApplicationRecord < ActiveRecord::Base, a service object, and a caller in a third file). Refs DeusData#1701 Assisted-by: OpenCode:anthropic/claude-opus-5 Signed-off-by: Ben Fairless <[email protected]>
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
|
Reviewed slice 4. The wiring is right and the Zeitwerk argument is correct. There is one consequence of it I want measured before this lands, and you have already named half of it yourself. The filter exemption is justified. Ruby constants live in one global namespace and Rails/Zeitwerk autoloads them with no The consequence: Ruby has the Rust exemption without the Rust mitigationRead the branch you edited alongside its own comment:
Rust survives the exemption because the registry is its normal path and the full per-file build is its fallback. Your PR description says the quiet part plainly: "there is no prebuilt tier-2 registry yet." So for Ruby the fallback is the only path. Following it through:
So every Ruby file rebuilds a class table over the entire project's defs. That is F × D, and D grows with F — the corpus-proportional shape, and precisely what "a language without a Tier-2 shared registry is automatically quadratic" describes. A Rails app is exactly the kind of repo where F is large. I want to be honest about the limits of this: I am reasoning from the call structure, not from a measurement. It may be perfectly acceptable at real Ruby project sizes — the constant factor is small and the class table is cheap per def. But that is a claim to check rather than assume, and right now nothing checks it. What I would likeEither a measurement, or — better, because it is permanent — a Ruby fixture under The complexity suite auto-discovers fixture directories at that path ( If the ratio comes back at ~2, this merges and the fixture stops the next person reintroducing the coupling. If it comes back at ~4, better to know now than after a Rails user reports a slow index. Smaller notesThe split is clean and I checked the part that is easy to get wrong — Registering methods on both instance and singleton receiver keys because Clearance: the two flagged files here ( Baseline note: the |
Bring the seven review fixes from DeusData#1711 into the cross-file slice. The only conflict was tests/test_ruby_lsp.c, where both sides added tests; both are kept. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
The class table was a per-file array scanned linearly. Add a read-only project-wide table the resolver consults after the file's own, hashed by QN and by dotted constant path, and let method lookup search every reopening of a class declared in more than one file. The table is only populated by the builder in the next commit, so this changes nothing on its own. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
Every Ruby file rebuilt a class table over the whole project's definitions, so cross-file resolution cost grew with files x definitions. On a replicated fixture the complexity suite's per-file registry work rose by 3.96x when the corpus doubled, against a linear ~2. Build one sealed, read-only registry (a finalized method registry plus a hashed class table keyed by QN and by dotted constant path) before the resolve workers start, and resolve each file against it with O(1) lookups, as the Go, Python and TypeScript cross registries do. The resolver reads the shared table after the file's own declarations, so a class declared in the file still wins. Two cross-file gaps close with it: - a compact `class A::B` in another file is now found: its constant path is the dotted `A.B`, where the raw `A::B` spelling never matched; - a class reopened in another file groups with its other declarations, so a method defined in any of them resolves, and a superclass named in only one carries over. The constructor edge lands on the first. The per-file fallback entry point is kept, now a thin wrapper that builds a registry for one call. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
Add the Ruby complexity fixture the maintainer asked for. The suite replicates it k and 2k times and gates on the ratio of per-file registry work: ~2 is linear, ~4 is files x corpus. It measured 3.96 before the shared registry and 2.00 after. Add unit rows for a class reopened across files, a compact A::B class in another file, and one registry serving several files unchanged, and a pipeline-level row that indexes three Ruby files and checks the cross-file and reopened-class edges come from the type-aware pass. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
|
Hi @benrfairless, thank you again for #1711, which landed on main on November 2 as fabc2b0. Ruby has a real per-file resolver now, and that is your work. Thank you as well for not waiting on us here: your October 20 commits already give this slice most of what we discussed in #1711's review. There is a project-wide registry built once and sealed, a hashed class table keyed by QN and by constant path, grouped reopenings, a cross-file cost row and a complexity fixture. That made this review much easier. Here is the whole review in one go, against current main (f031c30). Everything below comes from reading the code; we have not built this head yet. 1. Rebase onto current main (or merge main in, whichever you prefer).
For orientation: the same functions you touch now contain our three small follow-ups from #1711 (0210ea9 single-walk visit stamps, 214a9ac iterative constant path, 435baf6 seed NULL checks). Main also changed the signature of 2. The shared read-only registry has the right shape. Four points against our checklist (finalize after registration, no lookup in the registration loop, no registry tail scan, shared registries read-only). a. Keeping the table read-only after the merge. 0210ea9 stores the per-walk visit marks inside 3. The complexity fixture: thank you, and one change so it can fail.
4. Merge reopened classes at build time. Today a reopening is found by walking its group at lookup time. Please build one merged record per constant path instead, holding its methods (each still pointing at the def in its own file), its superclass and later its mixins, so that a lookup is one hash probe however many files reopen the class. Two details to cover while you are there:
5. Instance + singleton double registration: please justify it with numbers or drop it. 6. One more fail-closed case, across files: mixins of classes declared elsewhere. Smaller: the new cross-file rows match the caller with On our side, once the rebased head is up: we will re-run the per-language benchmark from #1711 on rails, redmine, mastodon and loomio: CALLS before and after, a hand-checked precision sample, a determinism check, wall time and memory. We will also re-judge the ActiveRecord-finder stratum. On #1711 it came out 17/17, but only for models declared in the same file. With the shared table, the rule now also fires for models declared in other files, which is the usual Rails layout, so it gets a fresh sample. The bar is at least 95% correct. If it lands below that, we would ask you to keep the finder rule same-file for now and move its cross-file part into a follow-up, so the rest of this slice isn't held up. CI on the October 20 head was red because of the language-count contract on the old base, which main has fixed since; the rebase clears it. #1713 (the docs slice) is waiting on this one, and we will pick it up as soon as this lands. Thank you again. This is careful work, and the shared registry is already most of the way there. |
Bring the branch up to current main (fabc2b0 and later) so the cross-file slice is reviewed against the merged per-file resolver. Conflicts, all in the three files the review named: - ruby_lsp.h: keep both field sets on RubyClassInfo (main's seen_instance/seen_singleton, this branch's group_head/shard_next). - test_ruby_lsp.c, test_lsp_resolution_probe.c: rebuilt as main's version plus this branch's diff, not by joining the conflict sides. Semantic fix: main stamps visit marks inside RubyClassInfo, which now can be an entry of the shared table read by every resolve worker. Marks for shared entries go in a per-file hash keyed by shared index, so the shared table stays const and a walk's result no longer depends on what another worker stamped. Also count the group walks with CBM_LSP_WORK. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
A class reopened in R files was found by walking its group at lookup time, and each reopening walked to the end of its group to join it. A constant reopened R times cost about R^2/2 to build, and every call site that missed on the head's own methods (the normal case for an inherited method) paid R, in every file. Keep one merged record per constant path. The head is the entry with the smallest QN, so a full build and the incremental closure route, which order defs differently, agree. Every reopening's methods register under the head's QN, so a lookup is one hash probe. The superclass comes from the smallest-QN declaration that has one, and a file that reopens a class without a superclass now falls back to the merged record for inherited methods and the ActiveRecord model check. A method defined by two reopenings resolves to the smaller QN. The new work row measures both costs with CBM_LSP_WORK. Doubling the reopenings from 64 to 128 took the build from 1953 to 8001 (ratio 4.10) and one fixed file's resolve from 728 to 1312 (1.80); with the merged record they are 212 to 386 (1.82) and 130 to 130 (1.00). The resolve gate is 1.5 rather than 3, because a per-lookup group walk only doubles when R does and would pass a gate of 3. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
CBMLSPDef did not say which side of a class a method lives on, and it carried no include, prepend or extend. Both matter across files. Registering every method on the instance and the singleton key let an instance call hit a `def self.x` on the receiver's class and stop there, never reaching the inherited instance method Ruby would call. And a class from another file had no mixins in the shared table, so a module it includes that overrides a superclass method resolved to the superclass's version. The extractor now flags a Ruby `def self.m` as singleton and records a class body's mixin macros as "<k>:<Const>" pairs. Both go through CBMDefinition, CBMLSPDef and the persisted lsp surface, so the incremental route sees what a full build does. Each method registers on exactly one key. Mixins are resolved once at build time, from the scope that declared them, into the merged record of their constant, in declaring-QN then source order; a module the table cannot see leaves the lookup blocked, as within one file. A method inside `class << self` is not emitted as a definition by the extractor, so it has no entry in the table and yields no edge. mixin_refs also goes into the result compactor and the memory census: without it the string was left pointing into a scratch arena that is freed before the cross-file pass reads it (a use-after-free under AddressSanitizer, caught by the new pipeline row). Also clang-format the lines of the earlier commits in this series that the formatter changes. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
The ActiveRecord finder rule (User.find -> User, relation chains keep the model) now also fires for a model declared in another file, the usual Rails layout. Keep that cross-file half behind one switch, ruby_ar_finder_cross_file, so it can go back to same-file models only by changing one constant if a precision sample says to. A test seam flips it, and a row covers each mode. Also match the caller exactly, not by substring, in the cross-file rows, as find_resolved_exact does for the single-file ones. Fix the stale 'fallback cbm_pxc_run_one path' comments on Ruby, and state the bound on Ruby table builds in a small incremental closure (at most CLOSURE_BUDGET_FLOOR_FILES). Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
The merge of main left two .rej files from re-applying this branch's test changes onto main's versions. They are not part of the change. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
Add a call that misses on a reopened class's own methods to the Ruby complexity fixture (CxWidget.new.label, inherited from CxBase), so the replicated corpus exercises the merged-record lookup end to end. Compare the shared class array and both hash indexes byte for byte before and after several files resolve against them. The count check the row had cannot see a written field, such as a visit mark. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
|
Thanks for the thorough review. Everything below is against current main (e0c7a1d at the time of the merge). I merged main in rather than rebasing, because the branch already carried two merge commits and the per-file commits that landed on main under different SHAs. The diff is now 15 files, +1608/-41. 1. Merge. The three conflicts were the three you named. 2a. Shared table stays read-only. Visit marks for shared entries now live in a small per-file hash keyed by shared index, sized by what the file touches. Per-file entries keep their inline marks. No cast, no non-const lookup. 2b, 2c and 4. One merged record per constant path. I replaced
3. Complexity gate.
The build gate is 3. I made the resolve gate 1.5, not 3, because a per-lookup group walk only doubles when R does, so a gate of 3 would have let it through. The old code is over both gates (the row stops at the first failed assertion, which was the build one). I also added 2d. The two stale comments are fixed. I left the lazy build out of this PR and put a comment at the incremental closure site stating the bound (at most 5. Singleton and instance. I added the def flag. The extractor sets 6. Mixins across files. The extractor now records a class body's A bug this turned up. The new Finder rule. The cross-file half of the ActiveRecord finder rule is behind one constant, Test status. The Assisted-by: Claude Code:claude-sonnet-5-5 |
One conflict, in pass_lsp_cross.c: main added the Java cross registry on the line where this branch adds the Ruby one. Both are kept. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ben Fairless <[email protected]>
Turn ruby_ar_finder_cross_file off by default, as agreed in review for the case where the cross-file finder stratum misses the 95% precision bar. A fixed-seed sample of 48 edges that only the cross-file half produces, on rails, redmine, mastodon and loomio, was 38/48 correct. Nine of the ten misses are relation chains (Model.where(...).m) typed as a model instance, so m lands on an instance method; in Ruby a relation answers m itself or delegates it to the model's class methods. The tenth is a same-named test model from another gem. The test seam now returns the previous setting, so a test restores what it found instead of a fixed value; with a fixed reset the default-mode row could not fail. The two rows that need the cross-file half switch it on explicitly, the default row asserts same-file only, and the UsersController row matches its caller exactly. Signed-off-by: Martin Vogel <[email protected]>
One conflict, in src/pipeline/pipeline.c (log_result_census): main added client_route_path and re-wrapped the str_def_misc sum, and this branch adds mixin_refs to the same sum. Resolved as main's lines plus the branch's term. Signed-off-by: Martin Vogel <[email protected]>
|
Hi @benrfairless, thank you so much for this round. You answered every point of our November 3 review, and in the way we hoped: one merged record per constant path with a smallest-QN head, so a full build and the incremental route agree; visit marks for shared entries kept per file, so the shared table really is read-only; an extractor flag so each method registers on exactly one key; mixins carried across files, with an unseen module failing closed; and a gate that counts work and fails on the old tail walk. The ruby_ar_finder_cross_file switch you added turned out to be exactly the right call, as you will see below. Here is the benchmark we promised, on the same commits as the #1711 run: rails/rails (3bbf10e6), redmine (c08408c1), mastodon (147bfc1f) and loomio (6a29a527), main vs main with this branch (including our commit below), fresh indexes.
The ActiveRecord re-judge: we drew 48 edges that only the cross-file half of the finder rule produces (fixed seed, 14 per repo and all 6 on mastodon) and checked each against the source. 38/48 were correct (79%), below the 95% bar. Nine of the ten misses are one pattern: a relation chain such as Topic.where(id: x).update(...) or loomio's User.where(...).order(:id).with_write_lock(:id) is typed as a model instance, so the call lands on an instance method. In Ruby a relation answers those itself (Relation#update) or hands them to the model's class methods (def self.with_write_lock), never to an instance method. The tenth is the same-named class issue in item 1 below. So, as we said on November 3, the finder rule stays same-file for now. Since you allowed maintainer edits, we pushed two commits on top of yours (your own commits are unchanged):
One thing we would like fixed before we merge, because it moves edges that are right on main to wrong targets:
Once that is in, we expect to merge this, and #1713 right after it. The cross-file finder can come back in a small follow-up once a relation is typed apart from a record: find, find_by, first, last, take, create and new return the model, while where, order, includes and friends return a relation whose calls go to the relation API or the model's class methods. With your switch, turning it back on is then a one-line change. Since the branch now has our two commits on top of yours, please pull before pushing again. Thank you again. This is careful, well-measured work. |
What does this PR do?
Slice 4 of 5 splitting #1706. Wires Ruby cross-file resolution on top of the per-file resolver (#1711, merged).
cbm_ruby_build_cross_registrybuilds one sealed, read-only project table before the resolve workers start, and each file resolves against it. Ruby joins Rust in bypassing the own+imports module-def filter, because Ruby constants live in one global namespace and Rails/Zeitwerk autoloads them with norequire.How the table works:
is_singleton).include/prepend/extend) are carried in the table and resolved once at build time. A module the table cannot see blocks the lookup.ruby_ar_finder_cross_file).Work is counted, not timed:
rubylsp_cross_reopened_class_work_is_boundeddoubles a class's reopenings (64 to 128) and gates the registry build at a ratio under 3 and the resolve of one fixed file at under 1.5.A method inside
class << selfis not emitted as a definition by the extractor, so it has no entry in the table and gives no edge.Follow-ups, not in this PR: build the table lazily in small incremental closures (a comment states the bound), and widen the extractor for
class << self.Refs #1701
Checklist
git commit -s)make -f Makefile.cbm test): the Ruby-related suites pass locally. My local full run had 31 failures, all in theclisuite, which CI does not reproduce: the full test jobs pass on Ubuntu, macOS and Windowsmake -f Makefile.cbm lint-ci):lint-format,lint-no-suppressandlint-memory-corepass locally, and thelintjob passes in CI. My locallint-cppcheckreported 11 findings in code this PR does not change, which CI does not reportAssisted by Claude Code using claude-sonnet-5-5.