What happens
_resolve_typescript_member_calls binds this.greeter.greet() only when Greeter is
declared inside the same build. Split the two classes across two repositories and the
call disappears: the resolver has the receiver's type in hand, finds no declaration for
it, and drops the call without writing anything down. graph.json — the only artifact
merge-graphs and global add consume — records nothing, so no merge-time pass can
recover what was never written.
The merged two-repo graph is therefore missing exactly the edges that make it a call
graph. This is #3152 for TS/JS; Java, C++, C#, Swift and Objective-C already park.
Reproduction
app/src/app.ts
import { Greeter } from "greeter-pkg";
export class App {
constructor(private greeter: Greeter) {}
run(): void { this.greeter.greet(); }
}
lib/src/greeter.ts
export class Greeter {
greet(): void {}
}
$ graphify extract app -o app/graphify-out
$ graphify extract lib -o lib/graphify-out
$ graphify merge-graphs app/graphify-out/graph.json lib/graphify-out/graph.json --out merged.json
merged.json has no App.run -> Greeter.greet edge, and neither input graph.json
carries any trace of the attempt. Put both files in one build and the edge appears.
Two things had to be settled for TS/JS specifically
The uppercase-spelling arm must not park. The resolver types a receiver two ways: a
per-file table (constructor parameter properties, local new bindings, bare
type_identifier annotations) and "the receiver starts with a capital, so it is the
type". The second is a spelling heuristic that a namespace alias (import * as React),
a default import and a plain const object all satisfy. node_modules is excluded from
the scan (detect.py), so the overwhelming majority of receivers that resolve to no
local declaration through that arm are npm names — parking them would hand the merge a
pile of bare names to match with no evidence behind them. Only table-typed receivers
park.
One merge-side key for all eight suffixes. The merge pass checks that the declaring
file's suffix belongs to the parked language, which is what stops a Java Greeter from
answering through a Python class of the same name. TS and JS cannot be split here: a TS
class legitimately answers a JS call site, so two keys would drop every TS↔JS cross-repo
call. .ts .tsx .mts .cts .js .jsx .mjs .cjs share one key.
Also fixed on the way
The resolver was registered for .ts .tsx .mts .cts .js .jsx only, so a pure-ESM
(.mjs) or CommonJS-suffixed (.cjs) corpus never ran type-aware member-call
resolution at all — unrelated to merging, and visible in a single-repo build.
Relation to the origin gate (#2553)
The gate rejects a name-only match when the caller cannot see the matched type. It runs
after the single-definition check, so it only ever vets a name that did match locally.
The park happens at the zero-declarations point, strictly upstream of it, so parking can
never override a gate rejection. There is a test pinning that.
PR: sends the calls to the merge and adds the tests.
What happens
_resolve_typescript_member_callsbindsthis.greeter.greet()only whenGreeterisdeclared inside the same build. Split the two classes across two repositories and the
call disappears: the resolver has the receiver's type in hand, finds no declaration for
it, and drops the call without writing anything down.
graph.json— the only artifactmerge-graphsandglobal addconsume — records nothing, so no merge-time pass canrecover what was never written.
The merged two-repo graph is therefore missing exactly the edges that make it a call
graph. This is #3152 for TS/JS; Java, C++, C#, Swift and Objective-C already park.
Reproduction
merged.jsonhas noApp.run -> Greeter.greetedge, and neither inputgraph.jsoncarries any trace of the attempt. Put both files in one build and the edge appears.
Two things had to be settled for TS/JS specifically
The uppercase-spelling arm must not park. The resolver types a receiver two ways: a
per-file table (constructor parameter properties, local
newbindings, baretype_identifierannotations) and "the receiver starts with a capital, so it is thetype". The second is a spelling heuristic that a namespace alias (
import * as React),a default import and a plain const object all satisfy.
node_modulesis excluded fromthe scan (
detect.py), so the overwhelming majority of receivers that resolve to nolocal declaration through that arm are npm names — parking them would hand the merge a
pile of bare names to match with no evidence behind them. Only table-typed receivers
park.
One merge-side key for all eight suffixes. The merge pass checks that the declaring
file's suffix belongs to the parked language, which is what stops a Java
Greeterfromanswering through a Python class of the same name. TS and JS cannot be split here: a TS
class legitimately answers a JS call site, so two keys would drop every TS↔JS cross-repo
call.
.ts .tsx .mts .cts .js .jsx .mjs .cjsshare one key.Also fixed on the way
The resolver was registered for
.ts .tsx .mts .cts .js .jsxonly, so a pure-ESM(
.mjs) or CommonJS-suffixed (.cjs) corpus never ran type-aware member-callresolution at all — unrelated to merging, and visible in a single-repo build.
Relation to the origin gate (#2553)
The gate rejects a name-only match when the caller cannot see the matched type. It runs
after the single-definition check, so it only ever vets a name that did match locally.
The park happens at the zero-declarations point, strictly upstream of it, so parking can
never override a gate rejection. There is a test pinning that.
PR: sends the calls to the merge and adds the tests.