Skip to content

Park TS/JS member calls whose receiver type lives in another repo (#3152) - #3387

Open
xiongjianxu wants to merge 3 commits into
Graphify-Labs:v8from
xiongjianxu:feat/ts-cross-repo-member-calls
Open

Park TS/JS member calls whose receiver type lives in another repo (#3152)#3387
xiongjianxu wants to merge 3 commits into
Graphify-Labs:v8from
xiongjianxu:feat/ts-cross-repo-member-calls

Conversation

@xiongjianxu

Copy link
Copy Markdown
Contributor

Closes #3386. Continues #3152 (#3385 did Objective-C).

What changes

_resolve_typescript_member_calls now parks a member call whose receiver type is
declared nowhere in this build, instead of dropping it, and
link_cross_repo_member_calls finishes it once the graphs are composed.

Two commits, separable:

  1. .mjs/.cjs activation. The resolver claimed .ts .tsx .mts .cts .js .jsx, so a
    pure-ESM corpus never ran type-aware member-call resolution at all. The suffix set is
    now one constant the resolver both activates on and parks by. Single-repo bug, has its
    own test that fails without the fix.
  2. The parking. metadata.unresolved_calls on the caller (names only — no node ids,
    which the import edges silently dropped on Next.js codebase — short IDs vs full-path node IDs (regression of #256) #1529 remap and repo prefixing both rewrite), plus the "typescript" entry
    in _LANG_SUFFIXES.

The two precision calls, and why

Only table-typed receivers park. The resolver types a receiver either from the
per-file table (constructor parameter properties, local new bindings, bare
type_identifier annotations) or from spelling — an initial capital is taken to name the
type. That second arm is satisfied by import * as React, by default imports and by
plain const objects, and since node_modules is not scanned, most receivers that reach
the zero-declarations point through it are npm names. Parking them would send bare names
to the merge with nothing behind them, so the park is gated on not type_qualified.
test_a_namespace_style_receiver_is_not_parked pins it.

One _LANG_SUFFIXES key over all eight suffixes. A TS class legitimately answers a JS
call site, so splitting TS from JS would drop every TS↔JS cross-repo call.
test_a_typescript_call_binds_across_the_whole_js_family runs the four representative
suffixes.

The park is gated on a JS/TS suffix rather than a lang tag because TS raw_calls carry
no tag and this resolver iterates every language's — the same shape the Swift resolver
uses.

Interaction with the origin gate (#2553)

The gate runs after the single-definition check, so it only ever vets a name that did
match locally; the park sits at the zero-declarations point, strictly upstream and
disjoint. test_a_type_the_origin_gate_rejected_is_not_parked builds the gate's own case
(a local class Repo plus import type { Repo } from 'external-pkg') and asserts nothing
is parked, so a gate rejection cannot be laundered through the merge.

Known recall cost

interface_declaration, enum_declaration and type_alias_declaration all carry
_callable_class, so a type Greeter = … alias in a third repo makes a genuine class
match ambiguous and the merge emits nothing. That fails closed, which is the bar the
existing resolvers set, and narrowing it belongs with whatever decides that a type alias
is not a call target.

Verification

  • tests/test_cross_repo_member_calls.py + tests/test_ts_receiver_member_calls.py: 38 passed.
  • Full suite: 5319 passed, 93 skipped, with tests/test_ollama_retry_cap.py deselected —
    its 4 failures reproduce unchanged on v8 at c9f9901.
  • ruff check graphify tests: clean. Added lines are within 100 code points.

xuxiongjian added 2 commits September 7, 2026 17:58
`_DISPATCH` routes `.mjs`/`.cjs` to `extract_js` and the cross-file call gate
already lists them, but `typescript_member_calls` claimed only six suffixes, so a
repo written entirely in `.mjs` was the one shape where the resolver never ran and
`const s = new Svc(); s.doThing()` produced no edge.

Both the suffix set and the resolver now read one `_JS_TS_SUFFIXES` constant.
…po (Graphify-Labs#3152)

`_resolve_typescript_member_calls` binds `this.repo.save()` only when the
receiver's type is declared in the same build. With the type in another
repository the resolver held the receiver type and dropped the call, so
`graph.json` — the only artifact `merge-graphs` and `global add` read — recorded
nothing and no merge-time pass could recover it.

Those calls are now parked on the caller as `metadata.unresolved_calls`, and
`link_cross_repo_member_calls` finishes them after the merge. One
`_LANG_SUFFIXES` key covers all eight TS/JS suffixes: a TS class legitimately
answers a JS call site, and two keys would drop every TS<->JS cross-repo call.

Only a table-typed receiver parks. The uppercase-spelling arm also matches
namespace aliases and default imports (`React.createElement()`), and
`node_modules` is never scanned, so parking those would send npm names to the
merge to be matched by bare name. The park sits strictly upstream of the Graphify-Labs#2553
origin gate, which only ever vets a name that did match locally.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Extends cross-repo member-call parking to TypeScript/JavaScript by giving both a single typescript suffix key (.ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs) so a TS class can answer a JS call site and pure-ESM repos still run the resolver. When a receiver's annotated type is declared nowhere in the corpus, _resolve_typescript_member_calls parks the call on the caller for merge-graphs to finish — but only for a bare, unqualified name from a JS/TS source, so namespace/default-import-style uppercase receivers and types the local origin gate already rejected are left unparked.

Worth a look

  • Parked call re-emitted when a local same-name type exists but the gate rejected itgraphify/extract.py:3508 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1889 functions depend on the 306 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 544 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: _get_extractor() — 26 callers, 6 callees
  • new: link_cross_repo_member_calls() — 20 callers, 7 callees
  • new: run_pipeline() — 8 callers, 13 callees
  • …and 35 more — each is listed as a finding

Verification — 1889 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1724 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_resolve\_typescript\_member\_calls.

The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

· 2 grounded finding(s) anchored inline below; 41 more finding(s) on lines outside this diff (see the check run).


@pytest.mark.parametrize("declaring_file", ["src/greeter.ts", "src/greeter.js",
"src/greeter.mjs", "src/greeter.tsx"])
def test_a_typescript_call_binds_across_the_whole_js_family(declaring_file: str):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiontest_a_typescript_call_binds_across_the_whole_js_family()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert link_cross_repo_member_calls(G) == 0


def test_the_definition_answers_before_a_same_named_declaration():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiontest_the_definition_answers_before_a_same_named_declaration()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

A corpus-wide type index let a same-named class in another language stand in for
the receiver's type, which hid from the parking branch that nothing in the JS/TS
family declares it. SFC script blocks are in the family, so a class exported from
a .vue file still answers — here and at merge time.
@xiongjianxu

Copy link
Copy Markdown
Contributor Author

Self-audit pass over the bot review. One fix pushed; the review's finding does not reproduce.

Fixed — the declaration index was corpus-wide. _is_type_like_definition is language-blind, so a same-named class in another language (a PHP class Greeter, say) made type_defs non-empty for a TS receiver typed Greeter. Resolution then fell to the origin gate, which correctly refused it — but the parking branch had already been skipped, so the call was dropped instead of handed to the merge. The index is now gated with _lang_family(...) == "jsts", which is wider than the suffix tuple on purpose: .vue/.svelte/.astro script blocks are extracted with the TS/JS grammars, so a class exported from an SFC is in the same module graph and must keep answering. test_a_class_declared_in_an_sfc_script_block_still_answers pins that half, test_a_class_from_another_language_neither_answers_nor_blocks_parking the other. _LANG_SUFFIXES["typescript"] in cross_repo_calls gains the same three suffixes so the merge side agrees.

Does not reproduce — a parked call being re-emitted after the origin gate rejects it. Parking sits under if not type_defs: and returns immediately, so a name that matched anything locally never reaches it; the origin gate runs strictly later, on the branch where a local definition was found. test_a_type_the_origin_gate_rejected_is_not_parked pins exactly that case and passes. The reasoning behind it is deliberate: if a local declaration exists and the gate judged the evidence too weak, parking would ask the merge to accept a remote match on weaker evidence still.

Verification: ruff check graphify tests clean; full suite 5321 passed / 93 skipped, with only the four pre-existing tests/test_ollama_retry_cap.py env failures that v8 also shows here.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Extends TS/JS receiver-typed member-call resolution to park calls whose receiver type is declared nowhere in the corpus onto the caller node, so the cross-repo pass can finish them after a merge — but only for table-typed (annotated, non-qualified) receivers in JS/TS files, leaving uppercase namespace-alias/default-import shapes and gate-rejected local names unparked. Scopes the local type-definition index to the JS/TS family so a same-named class in another language neither answers a call nor makes the receiver look locally declared. Registers the resolver and its cross-repo suffixes across the full ESM/CJS/.vue/.svelte/.astro family and treats TS and JS as one key so a TS class answers a JS call site.

No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1891 functions depend on the 308 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 544 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: _get_extractor() — 26 callers, 6 callees
  • new: link_cross_repo_member_calls() — 20 callers, 7 callees
  • new: run_pipeline() — 8 callers, 13 callees
  • …and 35 more — each is listed as a finding

Verification — 1891 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1726 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_resolve\_typescript\_member\_calls.

The verifier did not have enough to check \_resolve\_typescript\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

· 2 grounded finding(s) anchored inline below; 41 more finding(s) on lines outside this diff (see the check run).


@pytest.mark.parametrize("declaring_file", ["src/greeter.ts", "src/greeter.js",
"src/greeter.mjs", "src/greeter.tsx"])
def test_a_typescript_call_binds_across_the_whole_js_family(declaring_file: str):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiontest_a_typescript_call_binds_across_the_whole_js_family()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert link_cross_repo_member_calls(G) == 0


def test_the_definition_answers_before_a_same_named_declaration():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiontest_the_definition_answers_before_a_same_named_declaration()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

TS/JS: a member call whose receiver type lives in another repo is dropped, not parked (#3152)

1 participant