Skip to content

Navigation Menu

Sign in
Sign up

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

Open
xiongjianxu wants to merge 3 commits into
Graphify-Labs:v8 from
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:v8 from
xiongjianxu:feat/ts-cross-repo-member-calls

Conversation

@xiongjianxu

@xiongjianxu xiongjianxu commented Sep 7, 2026

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
edited
Loading

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):

@graphify-labs graphify-labs Bot Sep 7, 2026

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():

@graphify-labs graphify-labs Bot Sep 7, 2026

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.

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
edited
Loading

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):

@graphify-labs graphify-labs Bot Sep 7, 2026

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():

@graphify-labs graphify-labs Bot Sep 7, 2026

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

Reviewers

@graphify-labs graphify-labs[bot] graphify-labs[bot] left review comments

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

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

AltStyle によって変換されたページ (->オリジナル) /