Skip to content

fix(scala): suppress weak short-name matches for receiver calls - #2366

Open
htarnacki wants to merge 3 commits into
DeusData:mainfrom
htarnacki:fix/scala-weak-member-suppression
Open

htarnacki wants to merge 3 commits into
DeusData:mainfrom
htarnacki:fix/scala-weak-member-suppression

Conversation

@htarnacki

@htarnacki htarnacki commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #2155. Stacked on #2361 (which is stacked on #2360): the first two commits here are those PRs, only the last commit (4a0f9660) is this one. Once they merge I'll rebase and the diff collapses to that commit.

Scala has no LSP resolver, so every receiver call reaches the registry. values.get(k), xs.foreach(_.register()) or xs contains n then bind an arbitrary project-wide get / register / contains by short name (suffix_match, unique_name, field_type_hint) and produce CALLS edges to unrelated classes. Python and TS/JS already avoid this through the receiver-aware weak-member guard (#592/#606/#1276); this PR extends that guard to Scala rather than adding a new mechanism.

extract_calls.c — scala_call_is_value_member sets is_method for a call that names a lower-case method on a value receiver: recv.m(...), curried recv.m(a)(b), named infix recv m arg, placeholder _.m(). Left unflagged, because the registry's receiver_chain_admits already judges them: this.m() / super.m(), chains rooted at an upper-case name (Utils.helper(), Bijections.finagle.toStack(x)) and upper-case applies through a package path (http.param.Streaming(x)). Bare calls are untouched. Symbolic operators (a + b) are not methods for this purpose.

pass_calls.c / pass_parallel.c — add CBM_LANG_SCALA to suppress_weak_member in both resolvers (the two gates must stay identical, per the existing comment). One exemption, cbm_weak_member_same_file_exempt in registry.c: a unique_name / field_type_hint match whose target sits in the caller's own file keeps its edge, so d.describe() still binds the inherited method the same file declares (the mkc_c7_scala_inherited_method shape). suffix_match — several same-named candidates picked by distance — stays suppressed even when it lands nearby; see the residual below for what that costs.

Before / after on twitter/finagle (ca472de, 1,898 .scala files)

#2361 head vs this branch, fresh CBM_CACHE_DIR each run, edge identity normalized as in #2361.

before after delta
nodes 28,987 28,987 0
CALLS 30,323 21,177 −9,146, 0 added
CALLS suffix_match 13,581 8,404 −5,177
CALLS unique_name 7,867 6,231 −1,636
CALLS field_type_hint 2,793 460 −2,333
CALLS import_map / same_module / qualified_suffix / lsp_* 6,082 6,082 0
IMPORTS 6,341 6,341 0
TESTS 658 467 −191 (derived from the dropped CALLS)
edges from non-Scala files identical

Precision proxy for the 9,145 removed edges (normalized; one more is a $-only rename): for each one I checked whether the target's owner class is named anywhere in the caller's source file (import, type annotation, constructor). 72% (6,596) target a class that the caller file never mentions — Bufs.split → CookieMapBenchmark.map, Netty4FormPostEncoder.makeNetty4Request → Stack.foreach, TracingFilterTest → postgresql.PgBuf.Reader.collect. 82% had more than one candidate recorded. Retargeting (a weaker edge replaced by a stronger one) accounts for 85 of them.

Residual recall cost, stated plainly. 219 of the removed edges were same-file suffix_match and a good share of those were right: delegating wrappers (memcached.Client.set → BaseClient.set, Transport.read → QueueTransport.read) and builder chains (MutableSpan.copyForImmediateLogging → MutableSpan.setName). The same shape also produced wrong ones — underlying.close() inside RefPushSession bound to RefPushSession.close out of 180 candidates — which is why I did not extend the exemption to suffix_match. Among the 28% of removals whose owner is mentioned in the caller file there are correct edges too (Memcached.serve → Server.serve, CachingPool.checkout → Service.close); recovering those needs receiver-type inference (field/param type → owner), which is a different change from this guard. Happy to open a follow-up issue for that if wanted.

The TESTS drop is the same mix: TypeTest.testRow → Row.intOrZero (correct, lost) alongside RedisTest.testDecodingInChunks → CookieMapBenchmark.map (noise, gone).

Tests

  • extraction (394): extract_scala_member_call_flags_is_method pins the flag on xs.foreach, _.register, values.get, curried values.getOrElse(..)(..), infix xs contains n; and pins it off for this.helper(), super.finish(), Utils.helper(), Bijections.finagle.toStack(n), http.param.Streaming(n), bare helper(), n + 1.
  • pipeline (300): pipeline_scala_receiver_suppresses_weak_method_edges + the parallel-resolver twin — registerAll → register, lookup → get, has → contains produce no edge across files with same-named methods; controls callsLocal → localHelper (bare, unique_name, cross-file) and show → describe (d.describe() on an inherited method declared in the same file) keep theirs. Red without the guard (three noise edges) and red without the exemption (describe lost).
  • registry (72): cbm_weak_member_same_file_exempt — unique_name/field_type_hint + same file → exempt; suffix_match, cross-file, non-Scala, NULL inputs → not.
  • edge_imports (72), matrix_known_classes (43) unchanged.

Checklist

  • Every commit is signed off (git commit -s) — required, CI rejects unsigned commits (DCO, see CONTRIBUTING.md)
  • Tests pass locally (extraction, pipeline, registry, edge_imports, matrix_known_classes suites; the 15 failures in the full runner are all test_cli.c install/uninstall paths — environment-dependent, none touch the pipeline; full make -f Makefile.cbm test left to CI)
  • Lint passes (lint-format with clang-format 21, lint-no-suppress; cppcheck 2.17.1 with the lint-cppcheck flag set clean on the touched files)
  • New behavior is covered by a test (reproduce-first for bug fixes)

@htarnacki
htarnacki requested a review from DeusData as a code owner September 26, 2026 13:25

@DeusData DeusData left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @htarnacki. This is a really careful piece of work, and the finagle numbers together with the honest residual-cost section (the 223 same-file suffix_match edges) make it easy to review.

The new commit itself (06f97613) looks right to us. The weak-member guard is Scala-gated in both resolvers (pass_calls.c and its pass_parallel.c twin). The same-file exemption is Scala-only in registry.c, the extractor flag is set only under CBM_LANG_SCALA, and there's one cbm_gbuf_find_by_qn per call, so there's no quadratic pattern. That is exactly the per-language shape we want for a suppressor. The tests would fail without the change, too.

The only thing holding it up is procedural. This branch carries #2360 and #2361 as its first two commits, so the changes we requested on #2360 are inside this diff as well: the owner-fallback edges, the per-hit membership scan, and the top-level-only handling. Once those land, a rebase down to 06f97613 should be quick to approve. A follow-up issue for receiver-type inference would be very welcome. Thank you again!

@github-actions

Copy link
Copy Markdown

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. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

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.

@htarnacki

Copy link
Copy Markdown
Contributor Author

Thanks. Rebased onto the reworked #2360 (09f704d2) and #2361 (0d720d52) heads; this PR is the single commit 06ef2f64 on top of them — same content as 06f97613 plus one lint fix:

  • pass_calls.c:653 knownConditionTrueFalse (cppcheck): res.qualified_name && res.qualified_name[0] was always true there because the empty-resolution case returns earlier in that function. Dropped the condition in pass_calls.c only; the pass_parallel.c twin keeps it because that resolver checks emptiness after the guard (line ~3003), so the guard there is live. The "MUST match" comment now says why the two differ by that one condition.

Re-measured on finagle against the new stack (PR body updated): CALLS 30,311 → 21,161 (−9,150, 0 added), suffix_match −5,181 / unique_name −1,636 / field_type_hint −2,333, and import_map + same_module + qualified_suffix + lsp_* identical at 6,027. IMPORTS unchanged at 6,308. Precision proxy unchanged: 72% (6,600) of the removed edges target a class the caller file never mentions.

Follow-up issue for receiver-type inference: #2384.

Once #2360 and #2361 merge I will rebase so the diff collapses to this one commit.

@DeusData

Copy link
Copy Markdown
Owner

Thank you, @htarnacki. We diffed 06ef2f64 against 06f97613: it's the same change plus the cppcheck fix, and the fix is correct. resolve_single_call returns on an empty resolution before the guard, and res isn't reassigned after that, so dropping the check in pass_calls.c while keeping it in pass_parallel.c is right. The comment explaining why the two differ by that one condition is a nice touch.

Our view is unchanged: this commit is ready. The landing order is #2360 -> #2361 -> this one, and we'll approve it once the first two land and the branch collapses to this commit. Thank you also for filing #2384 so quickly. We keep Issues for actionable defects, so the receiver-type-inference idea was moved to Discussions. Sorry for the mixed signal after we asked you for an issue; the Discussion, linked to this PR, is the right home for it.

Scala imports were parsed with the generic import fallback, which
ignored selector groups, aliases (`{A => B}`) and wildcards, and the
declared `package` clause was not recorded. IMPORTS edges then had to be
guessed from the physical directory layout, which does not have to
mirror the package in Scala projects.

- parse Scala `import` statements into one CBMImport per selector,
  keeping the alias as the local name and the full path as the target
- record the `package` clause as the file namespace; consecutive
  braceless clauses (`package a` / `package b`) are joined into `a.b`
- thread the file language through import resolution so Scala imports
  are resolved against declared packages; the resolver fails closed at
  every step instead of guessing
- build a (package, top-level name) -> node index once per pass, next
  to the namespace map, so package membership is an O(1) lookup instead
  of a scan of the package's file list per same-named hit; every file
  declaring a namespace contributes, so a Scala import of a Java class
  in a mixed package resolves. Only the Scala resolver reads it, so it
  is built only when a Scala file is among the pass's files
- "top level" is decided against the file's language-aware module QN
  (stem vs directory), so a Java nested type (`Request.Builder`, one
  segment below the directory module) is not mistaken for a top-level
  `Builder` of the package
- `import pkg.Name` binds only a top-level `Name` of a file declaring
  `pkg`: nested classes and methods neither match nor make the real
  top-level symbol look ambiguous; two top-level declarations of the
  same name in a split package yield no edge
- `import pkg.Owner.member` binds the member under the owner's QN and
  otherwise yields no edge, so a missing member never turns `member()`
  into a CALLS edge to the owner class via resolve_import_map
- `import pkg._` binds the lexicographically smallest declaring file so
  the target is identical on every platform; the index records that
  file (and the runner-up, for self-import) per package, so the pick is
  O(1) instead of a scan of the package's file list
- incremental parity: both incremental routes hand the passes only the
  changed files, so the namespace map and the Scala index used to know
  only the changed files' packages, and an import into an unchanged
  package resolved through the symbol fallback (or, fail-closed, not at
  all) until the next full index. The passes now persist each file's
  package clause on its File node, and the incremental routes read the
  clauses of the files that stay back into the pass context so the
  maps cover the whole project exactly as on a full build
- Scala package clauses feed only the Scala resolver: the generic
  first-declaring-file walk skips Scala files, so Java/Kotlin imports of
  a shared package prefix keep resolving exactly as before
- import-map lookups therefore bind an aliased local name to the exact
  imported object, so `Alias.method()` resolves with the `import_map`
  strategy

Tests: extraction of selectors/aliases, the package namespace and
chained package clauses; edge_imports guards that packages may ignore
the physical layout, that a Java import never binds to a Scala prefix
declarer, that a Scala import binds a Java class of a mixed package,
that a Java import of a package declared by Java and Scala files still
binds the Java file, that two same-named top-level symbols in a split
package give no edge, that a nested same-named symbol is ignored, that
an unresolvable `Owner.member` gives neither an IMPORTS nor a CALLS edge
to the owner, that chained package clauses resolve, that a Java nested
type of the same name neither binds nor makes the top-level Scala
symbol ambiguous, and that an incremental reindex (closure-repair and
legacy-partial route, each proven by the route probe) keeps the IMPORTS
and CALLS into an unchanged package; pipeline tests for alias
resolution (sequential + parallel), for member imports in a split
package, and that the Scala index is built only when a Scala file is
among the importers.

Closes DeusData#2153

Signed-off-by: Hubert Tarnacki <hubert.tarnacki@gmail.com>
A Scala `class Foo` (or trait/enum) and its companion `object Foo` share
a source name, so both containers and their methods were assigned the
same qualified name and overwrote each other in the graph. Use scalac's
`Foo$` suffix for the companion object in every place a class QN is
computed. A standalone `object Foo` with no companion keeps its plain QN,
so existing graphs only change where two nodes used to collide.

Resolution treats the pair as one importable/callable name:

- pass_pkgmap: the companion enters the top-level index under `Foo$`, so
  `import pkg.Foo` binds the class (which stands for the pair) and never
  trips the split-package ambiguity marker; `import pkg.Foo.member` looks
  the member up under `Foo$` first, since only object members are
  importable.
- registry: `Foo.make()` reaches `Foo$.make` through import_map,
  same_module, the qualified-tail disambiguator and the receiver-chain
  guard. A `$`-terminated segment is emitted only by this change, so the
  comparison is exact equality for every other language.

Class-body `val`/`var` definitions keep their module-level QN like every
other language (parent_class already records the declaring owner).

Closes DeusData#2154

Signed-off-by: Hubert Tarnacki <hubert.tarnacki@gmail.com>
Scala has no LSP resolver, so every receiver call reaches the registry.
`values.get(k)`, `xs.foreach(_.register())` or `xs contains n` then bind
an arbitrary project-wide `get` / `register` / `contains` by short name
(suffix_match, unique_name, field_type_hint) and produce CALLS edges to
unrelated classes. Python and TS/JS already avoid this through the
receiver-aware weak-member guard (DeusData#592/DeusData#606/DeusData#1276); extend it to Scala.

extract_calls.c sets `is_method` for a call that names a lower-case
method on a VALUE receiver: `recv.m(...)`, curried `recv.m(a)(b)`, named
infix `recv m arg`, and placeholder `_.m()`. Calls on `this`/`super`, on
chains rooted at an upper-case name (`Utils.helper`,
`Bijections.finagle.m`) and upper-case applies through a package path
(`http.param.Streaming(x)`) stay unflagged — the registry's
receiver-chain check already judges those. Bare calls are untouched.

pass_calls.c / pass_parallel.c enable the guard for CBM_LANG_SCALA with
one exemption (cbm_weak_member_same_file_exempt): a unique_name /
field_type_hint match whose target sits in the caller's own file keeps
its edge, so `d.describe()` still binds the inherited method the same
file declares. suffix_match — several same-named candidates picked by
distance — stays suppressed even when it lands nearby.

Measured on twitter/finagle (~1,900 Scala files, on top of DeusData#2153/DeusData#2154):
CALLS 30,309 -> 21,159 (-9,149, 0 added; IMPORTS unchanged). 72% of the
removed edges target a class that is never named anywhere in the caller
file; 82% had more than one candidate. Residual recall cost: 223
same-file suffix_match edges (delegating wrappers such as
`Client.set -> BaseClient.set`) go with them.

Closes DeusData#2155

Signed-off-by: Hubert Tarnacki <hubert.tarnacki@gmail.com>
@htarnacki

Copy link
Copy Markdown
Contributor Author

Rebased onto the new #2360 (bb3f1cdd) and #2361 (8116eaaa) heads; this PR is the single commit 4a0f9660 on top of them, same content as 06ef2f64 (the rebase touched only the two RUN_TEST lists, where #2361's new tests and this PR's sit next to each other).

Re-measured on finagle against the new stack (PR body updated): CALLS 30,323 → 21,177 (−9,146, 0 added), suffix_match −5,177 / unique_name −1,636 / field_type_hint −2,333, and import_map + same_module + qualified_suffix + lsp_* identical at 6,082. IMPORTS unchanged at 6,341. Precision proxy unchanged: 72% (6,596 of 9,145) of the removed edges target a class the caller file never mentions. The small shifts against the previous numbers all come from the #2360 rework (+33 Scala→Java IMPORTS and the CALLS they carry), not from this commit.

Tests: extraction 394, pipeline 300, registry 72, edge_imports 72, matrix_known_classes 43; lint-memory-core and clang-format clean on the stack.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scala: receiver.method(...) calls are bound to an arbitrary same-named symbol by short name (weak-member suppression not applied)

2 participants