147 lines
12 KiB
Markdown
147 lines
12 KiB
Markdown
# Design + status: chained static-factory / fluent call resolution
|
||
|
||
**Status:** SHIPPED for **13 languages** (C++, C, PHP, Java, Kotlin, C#, Swift, Rust,
|
||
Go, Scala, Dart, Objective-C, Pascal/Delphi) + a conformance pass. **TypeScript and Luau
|
||
were evaluated and intentionally skipped** (both gradually typed → the mechanism is +0 /
|
||
regresses on real code). See "Full README classification" below. Tracking issue:
|
||
**#750** (which began as "the statically-typed README languages" but that enumeration was
|
||
incomplete — it missed ObjC / Pascal / Luau).
|
||
|
||
**Motivation:** 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:
|
||
|
||
```java
|
||
Foo.getInstance().bar(); // bar() should resolve to Foo::bar, never a same-named decoy
|
||
```
|
||
|
||
Before this work, every statically-typed language **dropped the receiver** and
|
||
name-matched the bare method (`bar`), so in 7 of 9 languages it silently attached to a
|
||
**same-named method on an unrelated type** — a correctness bug, not just missing coverage.
|
||
|
||
---
|
||
|
||
## The 3-part mechanism (per language)
|
||
|
||
1. **Capture the factory's declared return type** — a per-language `getReturnType`
|
||
hook writes `nodes.return_type` (schema v5). `*Foo`→`Foo`, `List<Bar>`→`List`,
|
||
`pkg.Foo`→`Foo`, `-> Self` / `: self` / `this.type` → the declaring type.
|
||
2. **Preserve the chained receiver at extraction** — `tree-sitter.ts` (or a bespoke
|
||
extractor) encodes `Foo.getInstance().bar()` as the marker string
|
||
`Foo.getInstance().bar` (the `().` marker never appears in an ordinary ref). A
|
||
per-language gate keeps **instance** chains (`list.map().filter()`) bare so their
|
||
existing resolution is untouched — only capitalized-receiver / factory chains re-encode.
|
||
3. **Resolve AND VALIDATE** — at resolution the receiver's type is inferred from what
|
||
the inner call returns, then the outer method is resolved **on that type** and
|
||
validated: the method must exist on the type (or a supertype it conforms to), so a
|
||
wrong inference yields **no edge**, never a wrong one.
|
||
|
||
Three shared resolvers in `src/resolution/name-matcher.ts`, all calling
|
||
`resolveMethodOnType` (which has the conformance supertype-walk):
|
||
|
||
| Resolver | Receiver style | Languages |
|
||
|---|---|---|
|
||
| `matchCppCallChain` | `field_expression` (`Foo::instance().bar`) | C++, C |
|
||
| `matchScopedCallChain` | `::` (`Cls::for($x)->m`, `Foo::new().bar`) | PHP, Rust |
|
||
| `matchDottedCallChain` | `.` (`Foo.create().bar`) | Java, Kotlin, C#, Swift, Go, Scala, Dart |
|
||
|
||
**Conformance pass (#754).** When the chained method lives on a **supertype** the
|
||
return type conforms to (an inherited / default-interface / trait / mixin / embedded
|
||
method), the first pass can't see it — `implements`/`extends` edges aren't built yet.
|
||
So failed chain refs are deferred (`CHAIN_LANGUAGES` in `resolution/index.ts`) and
|
||
re-resolved in a second pass `resolveChainedCallsViaConformance()` after edges exist,
|
||
walking `context.getSupertypes(...)`.
|
||
|
||
**Adding a language:** `getReturnType` in `languages/*.ts`; encode the chained receiver
|
||
+ a node-type gate; add the language to the right `matchReference` gate (and
|
||
`CONSTRUCTS_VIA_BARE_CALL` if a bare capitalized call constructs the class); add to
|
||
`CHAIN_LANGUAGES`; synthetic tests + a real-repo A/B; bump `EXTRACTION_VERSION`.
|
||
|
||
---
|
||
|
||
## Coverage (validated — each via synthetic decoy/absent-method tests + a real-repo A/B)
|
||
|
||
| Language | PR | Receiver | Real-repo A/B (unique `calls` edges) | Notes |
|
||
|---|---|---|---|---|
|
||
| **C++ / C** | #645 (#742) | `field_expression` | — | The original: singletons / factories / chained getters. |
|
||
| **PHP** | #608 (#749) | `::` → `->` | — | `Cls::for($x)->method()` — the Laravel per-tenant client idiom. `: self`/`: static`. |
|
||
| **Java** | #751 | `.` | Guava **+1,507 / −0** | Missing-edge → purely additive. |
|
||
| **Kotlin** | #752 | `.` | arrow **+49 / −438** | Wrong-edge → precision win (438 removed = test/doc noise + wrong). Needed the capitalized-receiver gate + constructor-receiver handling. |
|
||
| **C#** | #753 | `.` | Newtonsoft +3 / NodaTime **+73 / −0** | Additive. Return type is the `returns` field; extension-method chains correctly don't resolve. |
|
||
| **conformance** | #754 | (resolver upgrade) | arrow **+22 / −0** | Supertype walk — enables Swift protocol-ext, Rust trait, Go embedded, Dart mixin, Java/Kotlin/C# inherited chains. |
|
||
| **Swift** | #755 | `.` | Alamofire / Kingfisher **0 / 0** | Neutral-safe (unique fluent names already bare-resolved). Needed a nested-extension naming fix (`KF.Builder`→`KF::Builder`). |
|
||
| **Rust** | #757 | `::` | clap **+937 / −775** | Precision win (622 wrong→right retargets, +162 net). `-> Self`; trait-default methods via conformance. Single-hop. |
|
||
| **Go** | #760 | `.` | gin **net-zero** | `New().Method()`; embedded structs via conformance. Variable-inner fallback. **Found + fixed a batched-resolver runaway** (a mutated `original.referenceName` looped the offset-0 batch → 5M edges / 1.4 GB; fixed by tying the fallback to the original ref + a non-progress guard). |
|
||
| **Scala** | #761 | `.` | gatling **+14 / −59** | Precision win (−59 = stdlib `Option`/`Iterator` `.map`/`.flatMap` the baseline mis-tied to gatling's `Validation::*`). Companion factories + case-class `apply`. |
|
||
| **Dart** | #762 | `.` | localsend hand-written **+17 / −10** | Precision win **+ constructors made first-class** (factory/named ctors `Foo.create()`/`Foo._()` are now indexed; unnamed `Foo()` stays `instantiates`). `dartCtorInfo` validates a ctor against the enclosing class name — handles a tree-sitter misparse where `@override (A,B) m()` makes `m()` look like a ctor. |
|
||
| **Objective-C** | #786 | message send | SDWebImage **+35 / −75** | Precision win. Chained message send `[[Foo create] doIt]` 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 on `X` (validated). The −75 are wrong `init` mis-matches retargeted to the right class. |
|
||
| **Pascal/Delphi** | #791 | `.` (`exprDot`) | PascalCoin **+19 / −18** | Precision win. `TFoo.GetInstance().DoIt()` over Pascal's `exprCall`/`exprDot`. getReturnType from the `typeref` (incl. interface returns `IFoo`). Re-encoding gated on the Delphi `TFoo`/`IFoo` type convention so capitalized *variable* chains stay bare. A constructor (no `: TBar`) or typecast `TFoo(x)` resolves on the class. 15 of the −18 are correct class→interface retargets (`GetInstance(): IAsn1OctetString`). |
|
||
| **TypeScript** | — | `.` | typeorm +0/−6 · nest **+0/−164** | **Evaluated, NOT shipped** — gradual typing; see below. |
|
||
| **Luau** | — | `:` / `.` | Fusion +0/−0 · matter +0/−0 | **Evaluated, NOT shipped** — gradually typed; additive-safe (missing-edge gap, no regression) but real Luau rarely annotates factory returns, so +0 on both benchmarks. Works for `Foo.create(): Bar` then `:doIt()` (synthetic). |
|
||
|
||
`EXTRACTION_VERSION` is now **18** (C++→…→Pascal chains→paren-less calls→free-routine attribution). Re-index with `codegraph index -f`
|
||
to pick up the newer extractor on an existing graph.
|
||
|
||
## Why TypeScript was skipped
|
||
|
||
The mechanism resolves a chain from the factory's **declared** return type. TypeScript
|
||
leans on **type inference** — e.g. NestJS's `Test.createTestingModule(m) { return new
|
||
TestingModuleBuilder(...) }` has no `: TestingModuleBuilder` annotation — so the
|
||
factory's type can't be recovered, the re-encoded chain can't resolve, and it **drops
|
||
the bare-name edge** the existing resolver found. Real-repo A/B was **+0 added on both
|
||
typeorm and nest** with a net recall regression (−164 on nest, mostly the ubiquitous
|
||
`Test.createTestingModule({…}).compile()` pattern). The removed edges were mostly
|
||
*wrong* (baseline mis-resolved `.compile()` to `ModuleCompiler::compile`), so it's
|
||
precision-positive but recall-negative — against the recall-first invariant, and adding
|
||
nothing where it doesn't hurt (TS method names are unique enough that bare-name already
|
||
lands them). It was fully implemented (5 synthetic tests passed, runaway-safe bare-name
|
||
fallback) and consciously not shipped. The only path to a TS win would be reading
|
||
**inferred** return types (resolving `return new X()` in the factory body) — a much
|
||
larger change. Full write-up on issue #750.
|
||
|
||
---
|
||
|
||
## Full README classification (all 21 languages)
|
||
|
||
The mechanism's real requirement is a **declared return type** to recover the receiver's
|
||
type — not "statically typed" (PHP qualifies via its `: self` / `: Type` return
|
||
declarations). Against the README's full supported-language list:
|
||
|
||
| Bucket | Languages |
|
||
|---|---|
|
||
| **Covered** (13) | C++, C, PHP, Java, Kotlin, C#, Swift, Rust, Go, Scala, Dart, Objective-C, Pascal/Delphi |
|
||
| **Evaluated, skipped** (2) | **TypeScript** — gradual typing → inference-typed factories can't be recovered; net recall regression. **Luau** — gradually typed; additive-safe but +0 on Fusion AND matter (real Luau rarely annotates factory returns). Both: the mechanism needs reliably-declared return types, which gradually-typed code too often omits. |
|
||
| **Pascal call-coverage follow-ups** | Two gaps from the chained-call work, both resolved. **Paren-less calls (#793):** Pascal lets a no-arg method drop its parens (`Obj.Free;`, `TFoo.GetInstance.DoIt;`), which parse as a bare `exprDot` and weren't extracted as calls at all. Now extracted, scoped to STATEMENT position (a bare dot in assignment/condition position is left alone — ambiguous with a field/property access). PascalCoin A/B **+1131 / −1**, all new edges resolve to methods. **Free-routine attribution (#795):** a procedure/function defined only in the `implementation` section (no interface decl, not a method) had no node, so its body's calls were lumped under the file; now it gets a function node and its calls attribute to it. PascalCoin A/B **+511 / −145** (file-level aggregates → per-routine edges). |
|
||
| **Out of scope — no declared return types** (6) | JavaScript, Ruby, Lua, Svelte, Vue, Liquid (Liquid has no methods/chains at all) |
|
||
| **Partial / separate** (1) | Python — only optional `-> T` hints; tracked as #578, not part of this mechanism |
|
||
|
||
So #750's original framing ("the 9 statically-typed README languages") was incomplete —
|
||
it missed three more typed languages, all now resolved: **Objective-C** shipped (#786,
|
||
same wrong-edge gap, mechanism ports directly); **Pascal/Delphi** shipped (#791, a clean
|
||
port for the paren'd chain — an initial "blocked" read was wrong, caused by probing only
|
||
the paren-less form); **Luau** evaluated and skipped (gradual typing → +0 on real repos,
|
||
additive-safe).
|
||
|
||
The through-line: this mechanism fits languages with **reliably-declared return types**
|
||
(the 13 shipped). Gradually-typed languages (TypeScript, Luau) omit them too often for
|
||
it to pay off, and dynamically-typed languages have none.
|
||
|
||
---
|
||
|
||
## Edge cases / model
|
||
- **Single-hop**: a chain re-encodes one hop; deeper hops (`a.b().c().d()`) keep the
|
||
bare name (the inner `()` defeats the `Class::method` split). Re-measure on deep
|
||
fluent-builder repos.
|
||
- **Validation, not guessing**: every resolver ends in `resolveMethodOnType`, so an
|
||
unknown / wrong inferred type produces **no edge** — the decoy / absent-method
|
||
guarantee that makes this safe to ship.
|
||
- **Per-language receiver gate** keeps instance chains bare so existing resolution is
|
||
never regressed; the A/B "removed" counts are wrong-edge corrections, not losses.
|
||
|
||
## Related work
|
||
- **Dynamic-dispatch / callback synthesis** (a *different* mechanism): observer /
|
||
EventEmitter / React-render / JSX-child / django-ORM edge synthesis lives in
|
||
`callback-edge-synthesis.md` + `dynamic-dispatch-coverage-playbook.md`.
|
||
- The verbose session working-notes for #750 are in
|
||
`.claude/handoffs/chained-call-multilang-probe.md` (scratch; this doc is the
|
||
permanent record).
|