Skip to content

Tracking: chained factory/singleton call resolution across statically-typed languages #750

Description

@colbymchenry

A call whose receiver is itself a call — a factory / singleton / builder that returns an object — should produce a calls edge to the chained method:

Foo.getInstance().bar();   // bar() should resolve to Foo::bar

This was fixed for C++ (#645) and PHP (#608). A probe of the other statically-typed README languages found the same gap in all of them — and in 7 of the 9 the chained method currently resolves to the wrong class (a same-named method on an unrelated type), which is a correctness bug, not just missing coverage.

The fix is the 3-part #645/#608 mechanism, per language: capture the factory's declared return type, preserve the chained receiver at extraction, then resolve and validate the chained method on the inferred type — so a wrong inference produces no edge, never a wrong one.

Language Probe result (Foo.factory().method()) Status
Java edge missing #751
Kotlin wrong edge → same-named decoy #752
C# edge missing #753
Swift wrong edge → same-named decoy #755
Rust wrong edge → same-named decoy #757
Go wrong edge → same-named decoy (same-package New().Method(); cross-package pkg.New() was #469) #760
Scala wrong edge → same-named decoy #761
Dart wrong edge → same-named decoy (factory-constructor call also unresolved) #762
TypeScript wrong edge → same-named decoy ⏭️ evaluated, skipped (see note)

Dynamically-typed languages can't use this approach (no declared return types to read): Ruby and plain JS are out of scope; Python is partial (optional -> T hints, see #578).

Each language is validated the same way before merge: synthetic decoy + absent-method safety tests, then a real-repo A/B on a public OSS repo (node count unchanged, 0 edges lost, +N chained edges recovered, precision spot-checked).


TypeScript — evaluated and consciously skipped

The deterministic chain mechanism was fully implemented and probe-validated for TS (5 synthetic tests passed, including decoy-collision precision and a no-regression instance-chain guard), but real-repo A/B showed a net recall regression, so it was not shipped:

Repo A/B (unique calls edges)
typeorm (495 files) +0 / −6
nestjs/nest (600 files) +0 / −164

Why TS is different from the other 8 languages. The mechanism resolves a chained call from the factory's declared return type. TS code leans heavily on type inference — e.g. NestJS's Test.createTestingModule(metadata) { return new TestingModuleBuilder(...) } has no : TestingModuleBuilder annotation — so the factory's return type can't be recovered, the re-encoded chain can't resolve, and it drops the bare-name edge the existing resolver found. A bare-name fallback (the Go-fallback pattern, runaway-safe) recovered most but not all of them; ambiguous same-named methods across files (compile on both ModuleCompiler and TestingModuleBuilder) still resolved differently than baseline. The removed edges were mostly wrong (baseline mis-resolved .compile() to ModuleCompiler::compile), so it's precision-positive but recall-negative — the opposite of the recall-first invariant.

Zero added edges on both real repos confirms the marginal value is near-nil on real TS: method names are unique enough that the existing bare-name resolution already lands them, and the strict return-type annotations the mechanism needs are too often absent. The synthetic decoy win is real but doesn't occur in practice.

Conclusion: 8 of 9 statically-typed languages + conformance shipped (#751, #752, #753, #754, #755, #757, #760, #761, #762). TypeScript is the one language where gradual typing makes the deterministic chain mechanism a net loss, so it is intentionally not shipped.


The three typed README languages the original enumeration missed

The "9 statically-typed languages" list above was incomplete — the README also supports Objective-C, Pascal/Delphi, and Luau, all of which declare return types. Probed and addressed:

Language Outcome
Objective-C Shipped (#786). Same wrong-edge gap ([[Foo create] doIt] → a same-named decoy). Ported the mechanism over message_expression; getReturnType skips nullability qualifiers (nonnull instancetype); a class-message factory returns the receiver class by convention, so [[X alloc] init] / singleton chains resolve. SDWebImage A/B +35 / −75 — all corrections (the −75 are wrong init mis-matches retargeted to the right class).
Luau ⏭️ Evaluated, not shipped — additive-safe but +0 on real repos. The mechanism works for annotated factory chains (Foo.create(): Bar then :doIt(); 5 synthetic-style checks pass) and its gap is missing-edge, so unlike TypeScript it's additive (no regression). But Luau is gradually typed and idiomatic code rarely annotates factory returns (Fusion's Expectation.new(value) has no : Expectation; typeof(Bar) returns don't even parse), so real-repo A/B was +0 / −0 on both Fusion AND matter. It doesn't meet the real-repo-win bar, so it was left unshipped (same gradual-typing conclusion as TS, minus the regression).
Pascal/Delphi 🚧 Blocked by a larger prerequisite, out of scope for this mechanism. Pascal is statically typed (so the mechanism would genuinely pay off), but its method-call extraction from procedure bodies is broadly incomplete: paren-less calls (TFoo.GetInstance.DoIt) parse as a bare exprDot (not in the extractor's callTypes), and even paren'd method calls (f.Regular()) produce no edge (no receiver-type tracking for Pascal locals). Building Pascal's call graph is a substantial standalone extractor effort; the chained-call port is a small part of it and a half-fix would be worse than none. Documented as a separate follow-up.

Final tally: the chained static-factory mechanism is shipped for 11 languages — C++, C, PHP, Java, Kotlin, C#, Swift, Rust, Go, Scala, Dart, Objective-C (+ conformance). TypeScript and Luau were evaluated and consciously skipped (gradual typing → the mechanism is +0 / regresses on real code). Pascal is blocked on prerequisite extractor work. Full design record: docs/design/chained-call-resolution.md.


Correction: Pascal/Delphi is shipped (#791), not blocked

My earlier "blocked" assessment was wrong — I'd probed only the paren-less form (TFoo.GetInstance.DoIt, which the extractor doesn't emit as a call). The paren'd form (TFoo.GetInstance().DoIt()) extracts fine and hit the chained-call gap like every other language (with a decoy it mis-resolved to a same-named method on an unrelated class). It's a clean port: getReturnType from the typeref; re-encode the chained exprCall-receiver, gated on the Delphi TFoo/IFoo type convention so capitalized variable chains stay bare; a constructor (TFoo.Create().…) or typecast (TFoo(x).…) resolves on the class itself. PascalCoin A/B +19 / −18 (15 of the −18 are correct class→interface retargets — GetInstance(): IAsn1OctetString now resolves on the declared interface). EXTRACTION_VERSION 16.

Revised final tally: 13 languages shipped — C++, C, PHP, Java, Kotlin, C#, Swift, Rust, Go, Scala, Dart, Objective-C, Pascal/Delphi (+ conformance). TypeScript and Luau evaluated and skipped (gradual typing → +0 / regression on real code). The only remaining out-of-scope languages are the genuinely dynamically-typed ones (JavaScript, Ruby, Lua, Svelte, Vue, Liquid; Python is partial via #578). Full design record: docs/design/chained-call-resolution.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions