feat(extract): TS/JS member calls on local new-binding + typed-param receivers (#1630)
The #1316 resolver handled `this.injectedField.method()`, but a receiver whose type comes from a local `const x = new Foo()` binding (Pattern A) or a type-annotated parameter — including inside a returned closure (Pattern B) — produced no calls edge, so `affected <method>` silently under-reported. - _ts_receiver_type_table: augment the per-file type table with local `new` bindings (name -> constructor type) and bare-typed parameters (`(svc: Svc)` -> svc: Svc), merged after the constructor-injection entries (which win on a name clash). Only a bare type_identifier is recorded — an array/union/generic/qualified/predefined type is skipped (precision). - walk_calls now descends into an inline/returned JS/TS closure that is not separately tracked in function_bodies (e.g. `return () => svc.doThing()`), attributing its calls to the enclosing function, instead of stopping at the arrow boundary. A tracked-body-id set prevents double-walking const-assigned arrows. The existing _resolve_typescript_member_calls then resolves both via the receiver type with its single-definition guard. Verified on the real-CLI shape (absolute paths + graphify-out cache): both patterns resolve, ambiguity binds to the right class (Svc not Cache), untyped/array-typed receivers emit nothing. 5 tests, full suite 2871. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
21294f0d4f
commit
4744dfefca
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge.
|
||||
|
||||
- Fix: the `query` reference doc's inline vocab/fallback snippets now read and write files with `encoding="utf-8"` (#1619 A2, thanks @edtrackai). On Windows (default cp1252) the bare `read_text()`/`write_text()` calls crashed on exactly the cross-language corpora the doc demonstrates (e.g. Cyrillic labels like `обработчик`). Fixed across all generated skill variants.
|
||||
|
||||
- Fix: `graphify update`/`watch` no longer leaves stale sources after a deletion or a destination-only rename (#1623 / #1622, thanks @oleksii-tumanov). When the last supported file was deleted, or a rename reported only its destination in `changed_paths`, the removed source's nodes lingered in `graph.json`. The rebuild now reconciles extractor-backed sources against the files still present (code and document sources, subdirectory roots, legacy markers, symlinks, hyperedges) while preserving semantic and out-of-scope records.
|
||||
|
||||
@@ -2340,6 +2340,55 @@ def _csharp_member_type_table(root, source: bytes) -> dict[str, str]:
|
||||
return table
|
||||
|
||||
|
||||
def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None:
|
||||
"""Add TS/JS receiver bindings to ``table`` (name -> TypeName), for member-call
|
||||
resolution beyond the constructor-injected `this.field` case (#1630):
|
||||
|
||||
* local ``const/let/var x = new Foo()`` -> ``x: Foo`` (Pattern A);
|
||||
* a type-annotated parameter ``(svc: Svc)`` -> ``svc: Svc`` (Pattern B), so a
|
||||
call on the param — including inside a returned closure — resolves.
|
||||
|
||||
File-scoped, first-binding-wins (merged into the constructor-injection table,
|
||||
which is populated first and therefore wins on a name clash). Only a bare
|
||||
``type_identifier`` (a single class/interface name) is recorded — an array,
|
||||
union, generic, qualified, or predefined type is skipped (precision over
|
||||
recall, matching the receiver-typed resolvers for Swift/C#/C++)."""
|
||||
def _bare_type_ident(type_annotation):
|
||||
# type_annotation -> ": T"; accept only a single type_identifier child.
|
||||
idents = [c for c in type_annotation.children if c.type == "type_identifier"]
|
||||
others = [c for c in type_annotation.children
|
||||
if c.is_named and c.type not in ("type_identifier",)]
|
||||
if len(idents) == 1 and not others:
|
||||
return _read_text(idents[0], source)
|
||||
return None
|
||||
|
||||
stack = [root]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
t = n.type
|
||||
if t == "variable_declarator":
|
||||
name_n = n.child_by_field_name("name")
|
||||
value = n.child_by_field_name("value")
|
||||
if (name_n is not None and name_n.type == "identifier"
|
||||
and value is not None and value.type == "new_expression"):
|
||||
ctor = value.child_by_field_name("constructor")
|
||||
if ctor is not None and ctor.type in ("identifier", "type_identifier"):
|
||||
name = _read_text(name_n, source)
|
||||
tname = _read_text(ctor, source)
|
||||
if name and tname and name not in table:
|
||||
table[name] = tname
|
||||
elif t == "required_parameter" or t == "optional_parameter":
|
||||
pat = n.child_by_field_name("pattern")
|
||||
ann = n.child_by_field_name("type")
|
||||
if pat is not None and pat.type == "identifier" and ann is not None:
|
||||
tname = _bare_type_ident(ann)
|
||||
name = _read_text(pat, source)
|
||||
if name and tname and name not in table:
|
||||
table[name] = tname
|
||||
for c in n.children:
|
||||
stack.append(c)
|
||||
|
||||
|
||||
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
|
||||
"""Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``)
|
||||
in a method body, for receiver typing in the cross-file message-send pass
|
||||
@@ -4775,8 +4824,23 @@ def _extract_generic(
|
||||
return None
|
||||
return _read_text(scope, source)
|
||||
|
||||
_tracked_body_ids: set[int] = set()
|
||||
_JS_CLOSURE_TYPES = ("arrow_function", "function_expression")
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type in config.function_boundary_types:
|
||||
# JS/TS: an inline/returned closure not separately tracked in
|
||||
# function_bodies would otherwise drop its calls at this boundary.
|
||||
# Descend into it with the enclosing caller so `return () =>
|
||||
# svc.doThing()` links to the caller (#1630). Tracked closures
|
||||
# (const-assigned arrows) are walked with their own nid — skip to
|
||||
# avoid double-counting.
|
||||
if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
|
||||
and node.type in _JS_CLOSURE_TYPES):
|
||||
body = node.child_by_field_name("body")
|
||||
if body is not None and id(body) not in _tracked_body_ids:
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
return
|
||||
|
||||
if node.type in config.call_types:
|
||||
@@ -5282,6 +5346,14 @@ def _extract_generic(
|
||||
for _caller_nid, body_node in function_bodies:
|
||||
_swift_local_var_types(body_node, source, type_table)
|
||||
|
||||
# JS/TS: bodies already walked with their own caller_nid (const-assigned
|
||||
# arrows, methods). An INLINE/returned arrow or function-expression that is
|
||||
# NOT separately tracked (e.g. `return () => svc.doThing()`) is otherwise
|
||||
# skipped at the arrow boundary in walk_calls, losing its calls — so let
|
||||
# walk_calls descend into such untracked closures with the enclosing caller
|
||||
# (#1630 Pattern B). Guarding on the tracked set prevents double-walking.
|
||||
_tracked_body_ids.update(id(b) for _, b in function_bodies)
|
||||
|
||||
for caller_nid, body_node in function_bodies:
|
||||
walk_calls(body_node, caller_nid)
|
||||
|
||||
@@ -5388,6 +5460,13 @@ def _extract_generic(
|
||||
n["_callable"] = True
|
||||
if swift_extensions:
|
||||
result["swift_extensions"] = swift_extensions
|
||||
# TS/JS: augment the constructor-injection type table with local `new`
|
||||
# bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and
|
||||
# a call on a typed param (incl. inside a closure) resolve (#1630). The
|
||||
# constructor-injection entries are populated during the walk above and win on
|
||||
# a name clash (first-binding-wins in the helper).
|
||||
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
|
||||
_ts_receiver_type_table(root, source, type_table)
|
||||
if type_table:
|
||||
if config.ts_module == "tree_sitter_swift":
|
||||
result["swift_type_table"] = {"path": str_path, "table": type_table}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""TS/JS receiver-typed member calls beyond `this.field` (#1630).
|
||||
|
||||
The #1316 resolver handled `this.injectedField.method()`. This adds two receiver
|
||||
tiers whose type is statically known but was previously dropped, so
|
||||
`affected <method>` silently under-reported:
|
||||
|
||||
A. a local `const x = new Foo()` binding, then `x.method()`;
|
||||
B. a closure over a type-annotated parameter, `f(x: Foo) => () => x.method()`.
|
||||
|
||||
Resolution is by receiver type with the single-definition guard; an untyped or
|
||||
non-bare-typed receiver produces no edge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract
|
||||
|
||||
_SVC = "export class Svc {\n doThing(): number { return 1; }\n}\n"
|
||||
|
||||
|
||||
def _calls(tmp_path, files: dict[str, str]):
|
||||
for name, body in files.items():
|
||||
p = tmp_path / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
# Real-CLI shape: absolute input paths + a graphify-out cache subdir.
|
||||
r = extract([tmp_path / n for n in files], cache_root=tmp_path / "graphify-out")
|
||||
lbl = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
return {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"]
|
||||
if e["relation"] == "calls"}, r
|
||||
|
||||
|
||||
def test_local_new_binding_receiver(tmp_path):
|
||||
calls, _ = _calls(tmp_path, {
|
||||
"svc.ts": _SVC,
|
||||
"direct.ts": ('import { Svc } from "./svc";\nconst s = new Svc();\n'
|
||||
"export function usesDirect(): number { return s.doThing(); }\n"),
|
||||
})
|
||||
assert any("usesDirect" in s and "doThing" in t for s, t in calls)
|
||||
|
||||
|
||||
def test_closure_over_typed_param_receiver(tmp_path):
|
||||
calls, _ = _calls(tmp_path, {
|
||||
"svc.ts": _SVC,
|
||||
"closure.ts": ('import { Svc } from "./svc";\n'
|
||||
"export function register(svc: Svc): () => number "
|
||||
"{ return () => svc.doThing(); }\n"),
|
||||
})
|
||||
assert any("register" in s and "doThing" in t for s, t in calls)
|
||||
|
||||
|
||||
def test_new_binding_resolves_to_correct_class_under_ambiguity(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"svc.ts": _SVC,
|
||||
"cache.ts": "export class Cache {\n doThing(): number { return 2; }\n}\n",
|
||||
"d.ts": ('import { Svc } from "./svc";\nconst s = new Svc();\n'
|
||||
"export function f(): number { return s.doThing(); }\n"),
|
||||
})
|
||||
# must resolve to Svc.doThing (id contains svc), never Cache.doThing
|
||||
tgts = [t for _s, t in [(e["source"], e["target"]) for e in r["edges"]
|
||||
if e["relation"] == "calls" and "_f" in e["source"]]]
|
||||
assert tgts and all("svc" in t.lower() for t in tgts)
|
||||
assert not any("cache" in t.lower() for t in tgts)
|
||||
|
||||
|
||||
def test_untyped_param_receiver_emits_no_edge(tmp_path):
|
||||
calls, _ = _calls(tmp_path, {
|
||||
"svc.ts": _SVC,
|
||||
"n.ts": "export function g(x): number { return x.doThing(); }\n",
|
||||
})
|
||||
assert not any("doThing" in t for _s, t in calls)
|
||||
|
||||
|
||||
def test_array_typed_receiver_emits_no_edge(tmp_path):
|
||||
calls, _ = _calls(tmp_path, {
|
||||
"svc.ts": _SVC,
|
||||
"a.ts": ('import { Svc } from "./svc";\n'
|
||||
"export function h(xs: Svc[]): number { return xs[0].doThing(); }\n"),
|
||||
})
|
||||
assert not any("h(" in s and "doThing" in t for s, t in calls)
|
||||
Reference in New Issue
Block a user