Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions graphify/cross_repo_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
"csharp": frozenset({".cs"}),
"java": frozenset({".java"}),
"swift": frozenset({".swift"}),
# TS and JS share one key: a TS class legitimately answers a JS call site, so
# splitting them would drop every TS<->JS cross-repo call. An SFC script block is
# TS/JS too, so a class exported from `.vue` answers as well.
"typescript": frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
".vue", ".svelte", ".astro"}),
}

# A declaration owns its members through a `method` edge, except in C++, where an
Expand Down
27 changes: 25 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3391,6 +3391,11 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None:
_emit_call(caller, children[0], rc)


# Every suffix the TS/JS extractors claim, so a pure-ESM (`.mjs`) repo still activates
# the member-call resolver below.
_JS_TS_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")


def _resolve_typescript_member_calls(
per_file: list[dict],
all_nodes: list[dict],
Expand All @@ -3414,6 +3419,10 @@ def _resolve_typescript_member_calls(
defined in the caller's own file, a named import of the caller's file, or
contained in a module the caller's file imports. Otherwise EMIT NOTHING —
a false call edge is worse than a missing one (the C++ resolver's bar).

A receiver typed to a class this corpus declares nowhere is parked on the caller
for a merged graph to finish (#3152); the gate above never sees it, since it only
vets a name that did match locally.
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
Expand All @@ -3428,11 +3437,15 @@ def _key(label: str) -> str:

contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}

# Only the JS/TS interop family declares a type this receiver can hold (SFC script
# blocks included). A same-named class in another language must neither answer nor
# hide from the parking branch that nothing local declares the name.
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
if (_lang_family(n.get("source_file")) == "jsts"
and n.get("id") in contained and _is_type_like_definition(n)):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])

method_index: dict[tuple[str, str], str] = {}
Expand Down Expand Up @@ -3496,6 +3509,15 @@ def _key(label: str) -> str:
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if not type_defs:
# Table-typed only: an uppercase receiver also matches namespace aliases and
# default imports, and node_modules is unscanned, so those are npm names.
# The caller's family stands in for the `lang` tag TS raw_calls do not carry.
if not type_qualified and _lang_family(rc.get("source_file")) == "jsts":
_park_unresolved_member_call(
node_by_id.get(caller), callee, type_name, "typescript", rc,
)
continue
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
Expand Down Expand Up @@ -4551,7 +4573,8 @@ def _resolve_kotlin_qualified_calls(
LanguageResolver("ruby_member_calls", frozenset({".rb", ".rake"}), resolve_ruby_member_calls)
)
register_language_resolver(
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls)
LanguageResolver("typescript_member_calls", frozenset(_JS_TS_SUFFIXES),
_resolve_typescript_member_calls)
)
# C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in
# both suffix sets because it routes to extract_cpp or extract_objc by content; the
Expand Down
46 changes: 42 additions & 4 deletions tests/test_cross_repo_member_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
`merge-graphs` and `global add` read. The two-repo graph was missing precisely the
edges that make it a call graph.

The Java, C++, C# and Swift resolvers now park those calls on the caller node and
this pass finishes them after the merge. The cases below pin what it must NOT do
as much as what it must: the single-definition guard, the cross-repo-only scope,
and the language guard are what keep it from fabricating an edge from a name
The Java, C++, C#, Swift and TS/JS resolvers now park those calls on the caller
node and this pass finishes them after the merge. The cases below pin what it must
NOT do as much as what it must: the single-definition guard, the cross-repo-only
scope, and the language guard are what keep it from fabricating an edge from a name
collision.
"""
from __future__ import annotations
Expand Down Expand Up @@ -45,6 +45,7 @@ def _needs(module: str):
needs_cpp = _needs("tree_sitter_cpp")
needs_csharp = _needs("tree_sitter_c_sharp")
needs_swift = _needs("tree_sitter_swift")
needs_typescript = _needs("tree_sitter_typescript")


def _caller(repo: str, parked: list[dict], node_id: str = "app_run",
Expand Down Expand Up @@ -177,6 +178,33 @@ def test_a_defines_member_does_not_answer_a_java_call():
assert link_cross_repo_member_calls(G) == 0


PARKED_TS = [{"callee": "greet", "receiver_type": "Greeter", "lang": "typescript",
"line": "L4"}]


@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.

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.

# One key for TS and JS: a TS class legitimately answers a JS call site, so
# splitting the two would drop every TS<->JS cross-repo call.
G = _graph(
caller=_caller("a", PARKED_TS, source_file="src/app.ts"),
declarations=[(_declaration("b", "Greeter", declaring_file),
_method("b", ".greet()", source_file=declaring_file))],
)
assert link_cross_repo_member_calls(G) == 1, declaring_file
assert _added_calls(G) == {("a::app_run", "b::greeter_greet")}


def test_a_typescript_call_does_not_bind_to_a_python_declaration():
G = _graph(
caller=_caller("a", PARKED_TS, source_file="src/app.ts"),
declarations=[(_declaration("b", "Greeter", "greeter.py"),
_method("b", ".greet()", source_file="greeter.py"))],
)
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.

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 C++ class declares `void greet();` in its header (`defines`) and defines
# it out of line in the `.cpp` (`method`). Both hang off the one folded class
Expand Down Expand Up @@ -364,6 +392,16 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path):
("src/Greeter.cs", "class Greeter { public void Greet() {} }\n"),
marks=needs_csharp, id="csharp-field-receiver",
),
pytest.param(
"typescript", "greet",
("src/app.ts", 'import { Greeter } from "greeter-pkg";\n'
"export class App {\n"
" constructor(private greeter: Greeter) {}\n"
" run(): void { this.greeter.greet(); }\n"
"}\n"),
("src/greeter.ts", "export class Greeter {\n greet(): void {}\n}\n"),
marks=needs_typescript, id="typescript-parameter-property",
),
pytest.param(
"swift", "greet",
("src/App.swift", "class App {\n"
Expand Down
84 changes: 84 additions & 0 deletions tests/test_ts_receiver_member_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,87 @@ def test_same_file_type_still_resolves(tmp_path):
"export function runLocal(): void { r.save(); }\n"),
})
assert any("runLocal" in s and "save" in t for s, t in calls)


def test_a_pure_esm_corpus_still_activates_the_resolver(tmp_path):
# `.mjs` and `.cjs` route to extract_js like `.js` does, so a repo that uses
# only those must not be the one shape where the resolver never runs.
calls, _ = _calls(tmp_path, {
"svc.mjs": "export class Svc {\n doThing() { return 1; }\n}\n",
"app.mjs": ('import { Svc } from "./svc.mjs";\nconst s = new Svc();\n'
"export function usesDirect() { return s.doThing(); }\n"),
})
assert any("usesDirect" in s and "doThing" in t for s, t in calls)


def _parked(r) -> list[dict]:
return [entry for n in r["nodes"]
if isinstance(n.get("metadata"), dict)
for entry in n["metadata"].get("unresolved_calls", [])]


def test_an_annotated_type_declared_nowhere_is_parked_for_the_merge(tmp_path):
_, r = _calls(tmp_path, {
"app.ts": ("import { Greeter } from 'greeter-pkg';\n"
"export class App {\n"
" constructor(private greeter: Greeter) {}\n"
" run(): void { this.greeter.greet(); }\n"
"}\n"),
})
assert _parked(r) == [{"callee": "greet", "receiver_type": "Greeter",
"lang": "typescript", "line": "L4"}]


def test_a_namespace_style_receiver_is_not_parked(tmp_path):
# An uppercase receiver types the type by spelling alone, which a namespace
# alias, a default import or a plain const object satisfies just as well.
_, r = _calls(tmp_path, {
"app.ts": ("import * as React from 'react';\n"
"const s = new Svc();\n"
"export function render(): void { React.createElement(); }\n"),
})
assert _parked(r) == []


def test_a_type_the_origin_gate_rejected_is_not_parked(tmp_path):
# A local declaration exists and the gate refused it, so parking would ask the
# merge to accept a remote match on evidence the gate just judged weaker.
_, r = _calls(tmp_path, {
"fileb.ts": _LOCAL_REPO,
"filea.ts": ("import type { Repo } from 'external-pkg';\n"
"export class ReportService {\n"
" constructor(private repo: Repo) {}\n"
" run(): void { this.repo.save(); }\n"
"}\n"),
})
assert _parked(r) == []


def test_a_class_from_another_language_neither_answers_nor_blocks_parking(tmp_path):
# The declaration index is corpus-wide, so a same-named PHP class used to make the
# receiver look locally declared and the call was dropped instead of parked.
_, r = _calls(tmp_path, {
"app.ts": ("import { Greeter } from 'greeter-pkg';\n"
"export class App {\n"
" constructor(private greeter: Greeter) {}\n"
" run(): void { this.greeter.greet(); }\n"
"}\n"),
"Greeter.php": "<?php\nclass Greeter {\n public function greet(): void {}\n}\n",
})
assert _parked(r) == [{"callee": "greet", "receiver_type": "Greeter",
"lang": "typescript", "line": "L4"}]


def test_a_class_declared_in_an_sfc_script_block_still_answers(tmp_path):
# An SFC script block is TS, so its declarations belong to the same interop family
# as the caller's and must keep resolving.
calls, _ = _calls(tmp_path, {
"app.ts": ("import { Greeter } from './Greeter.vue';\n"
"export class App {\n"
" constructor(private greeter: Greeter) {}\n"
" run(): void { this.greeter.greet(); }\n"
"}\n"),
"Greeter.vue": ("<script lang=\"ts\">\nexport class Greeter {\n"
" greet(): void {}\n}\n</script>\n"),
})
assert any("run" in str(s) and "greet" in str(t) for s, t in calls), calls
Loading