diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 63a319e471..1533d291e1 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -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 diff --git a/graphify/extract.py b/graphify/extract.py index 7cc9e62c90..8d84727b89 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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], @@ -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: @@ -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] = {} @@ -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] @@ -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 diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index 3c16724646..dee3f705ab 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -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 @@ -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", @@ -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): + # 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(): # 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 @@ -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" diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index 682f95b07a..f0b21fd8d9 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -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": "\nexport class Greeter {\n" + " greet(): void {}\n}\n\n"), + }) + assert any("run" in str(s) and "greet" in str(t) for s, t in calls), calls