fix(extract): TS member-call gating + Kotlin grammar match (#2553, #2552, #2526, #2550, #2551)

TypeScript (#2553, #2552, thanks @Earthfreedom):
- _resolve_typescript_member_calls matched a receiver type by name alone
  and emitted EXTRACTED, so a third-party import could bind to an unrelated
  local class of the same name. It now requires the matched type to be
  same-file or imported by the caller's file, and tiers table-inferred
  receivers to INFERRED.
- calls inside a callback passed to another call (const h = wrapper(arrow))
  were never walked; the callback body is now walked and attributed to the
  declaration, through the same import-gated resolution so it cannot
  fabricate edges. The #2553 gate lands with #2552 by design.

Kotlin (#2526, #2550, #2551; adapts PR #2531, thanks @Mustaqeem66;
reports from @spaceBrownie and @thomasrengot-hub):
- match the bundled tree-sitter-kotlin 1.1.0 import node and resolve each
  import to the real target node (imports were silently dropped), so
  genuine calls promote to EXTRACTED via import evidence;
- a fully-qualified call com.example.Foo.bar() now produces a calls edge;
- a file the grammar cannot fully parse (e.g. one-line class C { val x })
  now warns instead of silently extracting nothing, and declarations
  recovered inside an error span keep their enclosing class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-08 23:37:32 +01:00
co-authored by Claude Opus 4.8
parent 3d19463484
commit cfc6a75c86
5 changed files with 1027 additions and 42 deletions
+379 -40
View File
@@ -606,39 +606,61 @@ def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, s
def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
# Grammar 1.1.0 (PyPI tree_sitter_kotlin) emits an `import` node whose
# children are the `import` keyword and a `qualified_identifier` (the dotted
# path), optionally followed by `.` `*` (wildcard) or `as` + `identifier`
# (alias). There is no `path` field. Older forks emit `import_header` with a
# `path` field or a bare `identifier` child; keep those branches so the
# extractor works across grammar generations (#2526, adapted from PR #2531
# by @Mustaqeem66).
path_node = node.child_by_field_name("path")
if path_node:
raw = _read_text(path_node, source)
module_name = raw.split(".")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
if path_node is None:
path_node = next(
(c for c in node.children if c.type == "qualified_identifier"), None
)
if path_node is not None:
raw = _read_text(path_node, source).strip()
else:
raw = next(
(_read_text(c, source).strip() for c in node.children
if c.type == "identifier"),
"",
)
if not raw:
return
# Fallback: find identifier child
# Wildcard (`import a.b.*`): imports a whole package, not a symbol. The last
# path segment is a PACKAGE name, so a symbol-level edge would dangle on (or
# collide with) an unrelated node that happens to share the package's name.
if raw.endswith(".*") or raw == "*" or any(c.type == "*" for c in node.children):
return
# Alias (`import a.b.C as D`): the alias is the identifier child after `as`.
alias = None
saw_as = False
for child in node.children:
if child.type == "identifier":
raw = _read_text(child, source)
tgt_nid = _make_id(raw)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
if not saw_as:
saw_as = child.type == "as"
elif child.type in ("identifier", "simple_identifier"):
alias = _read_text(child, source).strip() or None
break
module_name = raw.split(".")[-1].strip()
if not module_name:
return
# Target is the bare last segment for now; _resolve_kotlin_import_targets
# rewrites it to the real node id via the target_fqn stamped here, once the
# per-file package index exists. Unresolved targets stay dangling like other
# languages' external imports.
edges.append({
"source": file_nid,
"target": _make_id(module_name),
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
"metadata": sanitize_metadata({k: v for k, v in
{"target_fqn": raw, "alias": alias}.items() if v is not None}),
})
def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
@@ -888,7 +910,9 @@ _KOTLIN_CONFIG = LanguageConfig(
ts_module="tree_sitter_kotlin",
class_types=frozenset({"class_declaration", "object_declaration"}),
function_types=frozenset({"function_declaration"}),
import_types=frozenset({"import_header"}),
# Grammar 1.1.0 (PyPI tree_sitter_kotlin) names the import node `import`;
# older forks use `import_header`. Accept both (#2526).
import_types=frozenset({"import_header", "import"}),
call_types=frozenset({"call_expression"}),
call_function_field="",
call_accessor_node_types=frozenset({"navigation_expression"}),
@@ -2551,7 +2575,17 @@ def _resolve_typescript_member_calls(
parameter-property modifiers (``private repo: IUserRepository``) produce a
per-file type table mapping field names to their declared types. This pass
looks up the receiver field's type, finds a single-definition class/interface
owning a method with the callee name, and emits an EXTRACTED ``calls`` edge.
owning a method with the callee name, and emits a ``calls`` edge EXTRACTED
when the receiver names the type in source (``Type.method()``), INFERRED when
the type came from the table (the Swift/C#/Java tiering).
Origin gate (#2553): a name-only match is not evidence the caller can even
see the matched type. ``import type { Repo } from 'external-pkg'`` plus
``this.repo.save()`` must not fabricate an edge to an unrelated local
``class Repo`` in another file. The matched type must be origin-verified:
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).
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
@@ -2582,6 +2616,29 @@ def _resolve_typescript_member_calls(
if tnode is not None:
method_index[(src, _key(tnode.get("label", "")))] = tgt
# Origin maps (#2553), built like the Python resolver's module arm: key on
# stable NODE ids, not source_file strings (raw_calls keep their original
# pre-relativization paths, so a string join would miss under an explicit
# cache_root). ``contains`` maps a node to its file node; a member call's
# caller is usually a METHOD node, which hangs off its class via a ``method``
# edge instead, so fold those through to the owning class's file.
file_of_node: dict[str, str] = {}
for e in all_edges:
if e.get("relation") == "contains":
file_of_node[e.get("target")] = e.get("source")
for e in all_edges:
if e.get("relation") == "method":
owner_file = file_of_node.get(e.get("source"))
if owner_file is not None:
file_of_node.setdefault(e.get("target"), owner_file)
# ``imports`` targets are the imported symbol nodes; ``imports_from`` targets
# are module file nodes. A symbol id never collides with a file id, so one
# set serves both origin checks below.
imported_by_filenode: dict[str, set[str]] = {}
for e in all_edges:
if e.get("relation") in ("imports", "imports_from"):
imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target"))
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
@@ -2597,7 +2654,9 @@ def _resolve_typescript_member_calls(
continue
if receiver[:1].isupper():
type_name = receiver
type_qualified = True # the receiver names the type in source
else:
type_qualified = False
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
if not type_name:
continue
@@ -2612,19 +2671,38 @@ def _resolve_typescript_member_calls(
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
if target == caller or (caller, target) in existing_pairs:
# Origin gate (#2553): the caller's file must actually see the matched
# type — same file, a named import of the type, or a module import of
# the type's file. Otherwise a third-party type name that happens to
# collide with a local class fabricates an edge; emit nothing.
caller_file = file_of_node.get(caller)
type_file = file_of_node.get(type_nid)
imported = imported_by_filenode.get(caller_file, set())
if not (
(caller_file is not None and caller_file == type_file)
or type_nid in imported
or (type_file is not None and type_file in imported)
):
continue
existing_pairs.add((caller, target))
method_nid = method_index.get((type_nid, _key(callee)))
if not method_nid:
# Receiver typed, but the type has no such method. The old fallback
# (a `references` edge to the type node) was another fabrication
# vector; skip instead, matching the C# resolver.
continue
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
# `Type.method()` names the receiver type explicitly in source —
# EXTRACTED; a receiver typed via the constructor-injection/local table
# is inference — INFERRED (the Swift/C#/Java ternary).
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
@@ -3166,6 +3244,195 @@ def _resolve_objc_member_calls(
})
def _kotlin_package_index(per_file: list[dict]) -> dict[str, list[dict]]:
"""Group per-file results by the Kotlin package they declare.
``kotlin_package`` is stamped by the generic engine from the file's
``package_header`` (see extractors/engine.py); every node in the file
inherits it. Files with no package header contribute nothing.
"""
pkg_results: dict[str, list[dict]] = {}
for result in per_file:
pkg = result.get("kotlin_package")
if pkg:
pkg_results.setdefault(pkg, []).append(result)
return pkg_results
def _resolve_kotlin_import_targets(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Rewrite Kotlin ``imports`` edge targets from the bare last segment to the
node the written FQN actually names (#2526).
``_import_kotlin`` emits ``file --imports--> _make_id(last_segment)`` with
the full dotted path stamped as ``metadata.target_fqn``. That target dangles
(node ids carry a file-stem prefix), so build pruned every Kotlin import and
the import-evidence promotion in the shared call pass never fired. Here the
per-file ``kotlin_package`` declarations index each package's importable
(non-member) symbols by exact label; an edge whose ``target_fqn`` splits
into a known package P plus a Name defined exactly ONCE in P is rewritten to
that node id. The FQN is written verbatim in source, so the match is exact
confidence stays EXTRACTED. Anything else (external dependency, ambiguous
name) is left untouched and dangles like other languages' external imports.
Must run BEFORE the shared call pass builds its import-evidence index (it is
invoked directly in extract(), not via the tail registry run).
"""
pkg_results = _kotlin_package_index(per_file)
if not pkg_results:
return
# package fqn -> {importable label -> [node ids]}. Member labels (leading
# dot) are not importable as `P.Name`, and sourceless reference stubs are
# not definitions; both are excluded so they can't shadow the real symbol.
pkg_symbols: dict[str, dict[str, list[str]]] = {}
for pkg, results in pkg_results.items():
by_label = pkg_symbols.setdefault(pkg, {})
for result in results:
for n in result.get("nodes", []):
if not n.get("source_file") or n.get("type") == "namespace":
continue
label = str(n.get("label", ""))
if not label or label.startswith("."):
continue
by_label.setdefault(label.strip("()"), []).append(n["id"])
for e in all_edges:
if e.get("relation") != "imports":
continue
if not str(e.get("source_file", "")).endswith((".kt", ".kts")):
continue
fqn = (e.get("metadata") or {}).get("target_fqn", "")
pkg, _, name = str(fqn).rpartition(".")
if not pkg or not name:
continue
candidates = pkg_symbols.get(pkg, {}).get(name, [])
if len(candidates) == 1: # single-candidate guard: never fabricate
e["target"] = candidates[0]
def _resolve_kotlin_qualified_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Kotlin fully-qualified call expressions (#2550).
``com.example.nav.NavGraph()`` parses to a nested navigation_expression
chain; the engine flattens it and stamps the raw_call with
``qualified_prefix="com.example.nav"`` + ``lang="kotlin"`` when EVERY chain
segment is a plain identifier. The shared pass skips member calls, so these
raw_calls produced no edge at all this pass is strictly additive.
Resolution, guarded by exactly-one-candidate at every step:
* prefix == a declared package FQN P -> candidates are P's top-level
callables (functions/classes the file node `contains`) named callee;
* prefix == P + "." + TypeName where TypeName is a class/object declared
in P -> candidates are that type's methods (`method` edges, `.callee()`
label).
Zero or 2+ candidates -> no edge. The FQN is written verbatim in source, so
a unique match is EXTRACTED.
"""
pkg_results = _kotlin_package_index(per_file)
if not pkg_results:
return
raw = [
rc
for result in per_file
for rc in result.get("raw_calls", [])
if rc.get("lang") == "kotlin" and rc.get("qualified_prefix")
and rc.get("callee") and rc.get("caller_nid")
]
if not raw:
return
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
contains_by_source: dict[str, list[str]] = {}
methods_by_type: dict[str, list[str]] = {}
for e in all_edges:
rel = e.get("relation")
if rel == "contains":
contains_by_source.setdefault(e.get("source"), []).append(e.get("target"))
elif rel == "method":
methods_by_type.setdefault(e.get("source"), []).append(e.get("target"))
# package fqn -> {name -> [top-level callable nids]} and
# package fqn -> {name -> [top-level type nids]} (classes/objects).
pkg_callables: dict[str, dict[str, list[str]]] = {}
pkg_types: dict[str, dict[str, list[str]]] = {}
for pkg, results in pkg_results.items():
callables = pkg_callables.setdefault(pkg, {})
types = pkg_types.setdefault(pkg, {})
for result in results:
file_nid = next(
(n["id"] for n in result.get("nodes", [])
if n.get("source_file")
and n.get("label") == Path(str(n["source_file"])).name),
None,
)
if file_nid is None:
continue
for tgt in contains_by_source.get(file_nid, []):
n = node_by_id.get(tgt)
if n is None or not n.get("source_file"):
continue
name = str(n.get("label", "")).strip("()")
if not name or name.startswith("."):
continue
if n.get("_callable"):
callables.setdefault(name, []).append(tgt)
if n.get("_callable_class"):
types.setdefault(name, []).append(tgt)
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in raw:
prefix = rc["qualified_prefix"]
callee = rc["callee"]
caller = rc["caller_nid"]
candidates: list[str] = []
if prefix in pkg_callables:
# `P.callee()` — a top-level function or class constructor in P.
candidates = pkg_callables[prefix].get(callee, [])
else:
# `P.Type.callee()` — a method of a class/object declared in P.
pkg, _, type_name = prefix.rpartition(".")
type_nids = pkg_types.get(pkg, {}).get(type_name, []) if pkg else []
if len(type_nids) == 1:
wanted = f".{callee}"
candidates = [
m for m in methods_by_type.get(type_nids[0], [])
if str(node_by_id.get(m, {}).get("label", "")).strip("()") == wanted
]
if len(candidates) != 1: # zero or ambiguous -> no edge (god-node guard)
continue
tgt = candidates[0]
if tgt == caller or (caller, tgt) in existing_pairs:
continue
existing_pairs.add((caller, tgt))
all_edges.append({
"source": caller,
"target": tgt,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED", # the FQN is written verbatim in source
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
# Kotlin import-target resolution runs EARLY (directly in extract(), before the
# shared call pass builds its import-evidence index) — registering it in the
# tail registry would rewrite the targets after promotion already read them.
# It still uses the registry's LanguageResolver/driver for the suffix gate and
# failure isolation.
_KOTLIN_IMPORT_TARGET_RESOLVER = LanguageResolver(
"kotlin_import_targets", frozenset({".kt", ".kts"}), _resolve_kotlin_import_targets
)
# Register the cross-file, language-specific member-call resolvers into the shared
# registry (framework lives in graphify.resolver_registry). A new language plugs in
# by adding one register() call below — no edits to extract()'s body. Order
@@ -3222,6 +3489,15 @@ register_language_resolver(
resolve_pascal_inherited_calls,
)
)
# Kotlin fully-qualified call resolution (#2550): `com.pkg.Fn()` /
# `com.pkg.Object.method()` raw_calls the shared pass skips (member calls with
# no receiver). Runs in the tail registry like the other member-call resolvers;
# its sibling import-target pass runs earlier (see _KOTLIN_IMPORT_TARGET_RESOLVER).
register_language_resolver(
LanguageResolver(
"kotlin_qualified_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_qualified_calls
)
)
# Inline markdown link: [text](target "optional title"). The negative lookbehind
@@ -4874,6 +5150,28 @@ def extract(
file=sys.stderr, flush=True,
)
# #2543: collect sources that must NOT be stamped as up-to-date in the
# incremental manifest. Two cases:
# - extractor returned an error (missing optional extra, parse failure, …)
# - extractor exists but produced zero nodes (#1666 empty-source set)
# The CLI drops these from the stamped file set and clears any prior
# hashes so the next run retries them after the user installs the extra
# (or the transient failure self-heals) without deleting graphify-out/.
_failed_sources: list[str] = []
_failed_seen: set[str] = set()
for i, _p in enumerate(paths):
_res = per_file[i] or {}
_key = str(_p)
if _res.get("error"):
if _key not in _failed_seen:
_failed_sources.append(_key)
_failed_seen.add(_key)
continue
if (not _res.get("nodes")) and _get_extractor(_p) is not None:
if _key not in _failed_seen:
_failed_sources.append(_key)
_failed_seen.add(_key)
# #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST
# extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently
# contributes zero nodes. The #1666 warning above deliberately skips these (it
@@ -4926,6 +5224,32 @@ def extract(
file=sys.stderr, flush=True,
)
# #2551: a file the parser ACCEPTED but only with ERROR recovery (e.g. the
# Kotlin grammar rejecting one-line `class C { val x }` bodies, or Luau
# syntax the Lua grammar can't parse, #2520) extracts partially — sometimes
# to nothing but the file node — with no other signal. Neither warning
# above fires (nodes exist, no error marker), so surface it explicitly,
# naming the first error line so the user can find the construct.
_syntax_error_files: list[tuple[str, int | None]] = []
for i, _p in enumerate(paths):
_pe = (per_file[i] or {}).get("parse_errors")
if _pe:
_syntax_error_files.append((str(_p), _pe.get("first_error_line")))
if _syntax_error_files:
_shown = ", ".join(
f"{Path(x).name} (first error at line {ln})" if ln else Path(x).name
for x, ln in _syntax_error_files[:5]
)
_more = (
f" (+{len(_syntax_error_files) - 5} more)"
if len(_syntax_error_files) > 5 else ""
)
print(
f" warning: {len(_syntax_error_files)} file(s) had syntax errors and "
f"may be partially extracted: {_shown}{_more} (#2551)",
file=sys.stderr, flush=True,
)
all_nodes: list[dict] = []
all_edges: list[dict] = []
all_raw_calls: list[dict] = []
@@ -5533,6 +5857,17 @@ def extract(
# them from the indirect_call guard below to avoid false edges (#2137).
class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")}
# Kotlin import targets (#2526): rewrite each `imports` edge from the bare
# last-segment id to the node its written FQN names, via the per-file
# package declarations. Runs HERE — after the id-remap/disambiguation passes
# (ids are final) but before the import-evidence index just below reads the
# edges — so genuine imported calls get promoted INFERRED -> EXTRACTED. The
# tail registry run (run_language_resolvers below) would be too late.
run_language_resolvers(
paths, per_file, all_nodes, all_edges,
resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER],
)
# Build evidence index from import edges so cross-file calls backed by an
# explicit import statement can be promoted from INFERRED to EXTRACTED.
# Direct symbol imports (`import { foo }` / `const { foo } = require()`) are
@@ -5963,6 +6298,10 @@ def extract(
"edges": all_edges,
"input_tokens": 0,
"output_tokens": 0,
# Surfaces failed/empty AST sources to the CLI so the incremental
# manifest does not freeze them as processed (#2543). Callers that
# only read nodes/edges ignore this key.
"failed_sources": _failed_sources,
}
+146 -1
View File
@@ -1781,6 +1781,16 @@ def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: li
_JS_FUNCTION_VALUE_TYPES = frozenset({"arrow_function", "function_expression", "function", "generator_function"})
def _js_topmost_closures(node, out: list) -> None:
"""Collect the TOPMOST closure nodes (arrow / function expressions) under
``node``, without descending into a found closure its nested closures
belong to it and are reached by the walk_calls closure descend (#1630)."""
for c in node.children:
if c.type in _JS_FUNCTION_VALUE_TYPES:
out.append(c)
else:
_js_topmost_closures(c, out)
def _js_member_assignment_target(left, source: bytes):
"""Classify the symbol an `assignment_expression` LHS defines when its RHS
is a function. Returns (kind, owner_name, member_name) or None.
@@ -1952,7 +1962,8 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
elif value and (
is_exported_scalar_binding
or value.type in (
"object", "array", "as_expression", "call_expression",
"object", "array", "as_expression",
"satisfies_expression", "call_expression",
"new_expression",
)
):
@@ -1965,6 +1976,33 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
add_node_fn(const_nid, const_name, line)
add_edge_fn(file_nid, const_nid, "contains", line)
const_found = True
# #2552: `const handler = wrapper(async (req) => …)`
# created the const node above but, unlike the arrow
# branch, never tracked the callback's body — so
# walk_calls never descended into it and its calls
# were dropped. Track each TOPMOST closure in the
# initializer under the const's nid; nested closures
# are reached by the #1630 closure descend with the
# same caller, so appending them too would
# double-walk. `_tracked_body_ids` picks these up,
# so the descend skips them (no double-count).
inner = value
while inner is not None and inner.type in (
"as_expression", "satisfies_expression"):
inner = (inner.named_children[0]
if inner.named_children else None)
if inner is not None and inner.type in (
"call_expression", "new_expression"):
closures: list = []
_js_topmost_closures(inner, closures)
for closure in closures:
if local_bound_names is not None:
local_bound_names[const_nid] = (
local_bound_names.get(const_nid, set())
| _js_local_bound_names(closure, source))
body = closure.child_by_field_name("body")
if body:
function_bodies.append((const_nid, body))
if arrow_found:
return True
if const_found:
@@ -2158,6 +2196,70 @@ def _kotlin_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path:
return False
def _kotlin_package_name(root, source: bytes) -> str | None:
"""Dotted package FQN from the file's ``package_header``, or None.
Grammar 1.1.0 puts the path in a ``qualified_identifier`` child; older
forks use an ``identifier`` that spans the whole dotted text. Either way
the node's text IS the FQN.
"""
for child in root.children:
if child.type != "package_header":
continue
for c in child.children:
if c.type in ("qualified_identifier", "identifier"):
pkg = _read_text(c, source).strip()
return pkg or None
return None
return None
def _kotlin_nav_identifier_segments(nav, source: bytes) -> list[str] | None:
"""Flatten a Kotlin ``navigation_expression`` chain into its dotted
identifier segments (``com.example.Foo.bar`` -> [com, example, Foo, bar]).
Returns None when any segment is not a plain identifier a receiver that
is an expression, a call, ``this``, a string literal, etc. must never read
as a qualified name (#2550). Older grammars with a different navigation
shape also bail here, preserving their current behavior.
"""
segments: list[str] = []
node = nav
while node is not None and node.type == "navigation_expression":
named = [c for c in node.children if c.is_named]
# Grammar 1.1.0 shape: <receiver> "." <identifier> (the dot is unnamed).
if len(named) != 2:
return None
head, tail = named
if tail.type not in ("simple_identifier", "identifier"):
return None
segments.append(_read_text(tail, source))
node = head
if node is None or node.type not in ("simple_identifier", "identifier"):
return None
segments.append(_read_text(node, source))
segments.reverse()
return segments
def _first_parse_error_line(root) -> int:
"""1-based line of the first ERROR/MISSING node under ``root`` (#2551).
Descends the first erroring child at each level (document order), so it
lands on the earliest error region. Some recoveries set ``has_error``
without materializing an ERROR/MISSING child (zero-width recovery); the
deepest still-erroring node's line is reported for those.
"""
node = root
while True:
if node.type == "ERROR" or node.is_missing:
return node.start_point[0] + 1
child = next((c for c in node.children if c.has_error), None)
if child is None:
return node.start_point[0] + 1
node = child
def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None:
"""Resolve a C# type name, whether it was qualified, and its qualifier prefix."""
if node is None:
@@ -4015,6 +4117,17 @@ def _extract_generic(
walk(child, parent_class_nid=parent_class_nid)
return
# #2551: tree-sitter ERROR recovery can wrap declarations that plainly
# sit inside a class body (e.g. the Kotlin grammar choking on a one-line
# sibling member). The default recurse below deliberately drops
# parent_class_nid (an unknown wrapper usually IS a scope boundary), but
# an ERROR node is a parse artifact, not a scope — keep the enclosing
# class linkage for whatever declarations were recovered inside it.
if t == "ERROR":
for child in node.children:
walk(child, parent_class_nid=parent_class_nid)
return
# Default: recurse
for child in node.children:
walk(child, parent_class_nid=None)
@@ -4259,6 +4372,7 @@ def _extract_generic(
is_this_field_call: bool = False
swift_receiver: str | None = None
member_receiver: str | None = None
kotlin_qualified_prefix: str | None = None
# Special handling per language
if config.ts_module == "tree_sitter_swift":
@@ -4294,6 +4408,19 @@ def _extract_generic(
if child.type in ("simple_identifier", "identifier"):
callee_name = _read_text(child, source)
break
# #2550: `com.example.Foo.bar()` is a NESTED
# navigation_expression chain; the last identifier alone
# (`bar`) rarely matches in-file, so the call was dropped
# (the shared cross-file pass skips member calls). When
# EVERY chain segment is a plain identifier and there are
# >= 3 (a real dotted FQN, not `recv.method()`), stamp the
# dotted prefix for _resolve_kotlin_qualified_calls.
# member_receiver is deliberately NOT set: an uppercase
# receiver would trip the capitalized-receiver deferral
# below and regress in-file `Foo.bar()` resolution.
segments = _kotlin_nav_identifier_segments(first, source)
if segments is not None and len(segments) >= 3:
kotlin_qualified_prefix = ".".join(segments[:-1])
elif config.ts_module == "tree_sitter_scala":
# Scala: first child
first = node.children[0] if node.children else None
@@ -4581,6 +4708,11 @@ def _extract_generic(
receiver_type = (receiver_types or {}).get(member_receiver or "")
if receiver_type:
rc_entry["receiver_type"] = receiver_type
# Kotlin fully-qualified call (#2550): the dotted prefix +
# lang tag let _resolve_kotlin_qualified_calls claim it.
if kotlin_qualified_prefix:
rc_entry["lang"] = "kotlin"
rc_entry["qualified_prefix"] = kotlin_qualified_prefix
raw_calls.append(rc_entry)
# Indirect dispatch: a function passed BY NAME as a call argument
@@ -4922,6 +5054,19 @@ def _extract_generic(
if _ruby_mixin_calls:
raw_calls.extend(_ruby_mixin_calls)
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
# #2551: the parser recovered from syntax errors, so extraction may be
# partial (in the worst case, nothing but the file node). Record the first
# error's line so extract() can warn instead of reporting silent success.
# Rides on the result dict, so it survives the per-file AST cache.
if root.has_error:
result["parse_errors"] = {"first_error_line": _first_parse_error_line(root)}
# Kotlin (#2526/#2550): the declared package qualifies every node in the
# file; the import-target and qualified-call resolvers key their per-package
# symbol indexes off it.
if config.ts_module == "tree_sitter_kotlin":
_pkg = _kotlin_package_name(root, source)
if _pkg:
result["kotlin_package"] = _pkg
if callable_def_nids:
# Mark function / method / class defs with a `_callable` attribute so the
# cross-file indirect_call pass can resolve a by-name callback only to a real
+85
View File
@@ -0,0 +1,85 @@
"""Calls inside a callback passed to a module-level call must not be dropped (#2552).
`export const handler = wrapper(async (req) => { helperA(); })` has a
`call_expression` initializer, so `_js_extra_walk` took the const-literal branch
and never tracked the callback's body — `walk_calls` never descended into it and
the `helperA()` call was lost. The fix tracks each TOPMOST closure in such an
initializer under the const's nid, so its calls flow through the normal
machinery (import-evidence gate included).
The composition test guards the #2552/#2553 coupling: the newly-walked callback
body feeds member calls into `_resolve_typescript_member_calls`, whose origin
gate (#2553) must keep a third-party-typed receiver from fabricating an edge to
an unrelated local class.
"""
from __future__ import annotations
from graphify.extract import extract
_HELPERS = "export function helperA(): number { return 1; }\n"
def _extract(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)
r = extract([tmp_path / n for n in files],
cache_root=tmp_path / "graphify-out", parallel=False)
lbl = {n["id"]: n["label"] for n in r["nodes"]}
calls = {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"]
if e["relation"] == "calls"}
return calls, lbl, r
_HANDLER = ("import { helperA } from './helpers';\n"
"function wrapper(fn: (req: unknown) => Promise<number>) { return fn; }\n"
"export const handler = wrapper(async (req) => { return helperA(); });\n"
"export function control(): number { return helperA(); }\n")
def test_callback_body_calls_are_captured(tmp_path):
calls, _, _ = _extract(tmp_path, {
"helpers.ts": _HELPERS,
"handler.ts": _HANDLER,
})
assert ("handler", "helperA()") in calls, \
f"callback body call dropped; calls={sorted(calls)}"
# control: a plain named-function caller in the same file is unaffected
assert ("control()", "helperA()") in calls
def test_callback_body_call_is_not_double_counted(tmp_path):
_, lbl, r = _extract(tmp_path, {
"helpers.ts": _HELPERS,
"handler.ts": _HANDLER,
})
n = sum(1 for e in r["edges"]
if e["relation"] == "calls"
and lbl.get(e["source"]) == "handler"
and lbl.get(e["target"]) == "helperA()")
assert n == 1, f"expected exactly one handler -> helperA calls edge, got {n}"
def test_callback_member_call_is_origin_gated(tmp_path):
# Coupling guard: #2552 makes the callback body visible to the member-call
# resolver; #2553's origin gate must then block the name-only `Repo` match
# (third-party type) while the import-evidenced helperA() call resolves.
calls, lbl, r = _extract(tmp_path, {
"helpers.ts": _HELPERS,
"fileb.ts": "export class Repo {\n save(): void {}\n}\n",
"h.ts": ("import { helperA } from './helpers';\n"
"import type { Repo } from 'external-pkg';\n"
"function wrapper(fn: (repo: Repo) => void) { return fn; }\n"
"export const h = wrapper((repo: Repo) => "
"{ repo.save(); helperA(); });\n"),
})
assert ("h", "helperA()") in calls, \
f"import-evidenced callback call must resolve; calls={sorted(calls)}"
sf = {n["id"]: str(n.get("source_file", "")) for n in r["nodes"]}
fabricated = [e for e in r["edges"]
if e["relation"] in ("calls", "references", "indirect_call")
and sf.get(e["source"], "").endswith("h.ts")
and sf.get(e["target"], "").endswith("fileb.ts")]
assert not fabricated, \
f"third-party-typed receiver fabricated edge(s) to local Repo: {fabricated}"
+340
View File
@@ -0,0 +1,340 @@
"""Kotlin grammar-node-type mismatches (#2526, #2550, #2551).
PyPI tree-sitter-kotlin 1.x renamed/reshaped several nodes relative to the
older forks the extractor was written against:
* #2526 — imports are `import` nodes (an `import` keyword + a
`qualified_identifier`; no `path` field), so every Kotlin import edge was
silently dropped. Fixed by accepting the new node shape (adapted from PR
#2531 by @Mustaqeem66) and resolving the written FQN to the real node via
the per-file `package_header` declarations, which also unlocks the
INFERRED -> EXTRACTED import-evidence promotion.
* #2550 — `com.example.Foo.bar()` parses to a NESTED navigation_expression
chain; only the last identifier was kept, the receiver was never captured,
and the raw_call died in the member-call skip. Fixed by flattening
all-identifier chains into a `qualified_prefix` resolved against the
declared packages (exactly-one-candidate guarded).
* #2551 — the grammar rejects one-line `class C { val x }` bodies; consecutive
one-liners can dissolve the whole file's parse. Graphify now warns on any
file extracted through ERROR recovery (language-agnostic, also #2520) and
keeps class linkage for declarations recovered inside an ERROR span.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from graphify.extract import extract
def _extract(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)
old = os.getcwd()
try:
os.chdir(tmp_path)
r = extract([Path(n) for n in files],
cache_root=tmp_path / ".cache", parallel=False)
finally:
os.chdir(old)
return r
def _edges(r, relation):
return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation}
def _find(r, label, id_contains=""):
return next(n["id"] for n in r["nodes"]
if n["label"] == label and id_contains in n["id"])
# ── #2526: import edges ───────────────────────────────────────────────────────
_IMPORT_CORPUS = {
"model/Money.kt": (
"package com.demo.model\n"
"\n"
"class Money(val amount: Int)\n"
),
"model/Ledger.kt": (
"package com.demo.model\n"
"\n"
"class Ledger {\n"
" fun record(m: Money) { }\n"
"}\n"
),
"app/Main.kt": (
"package com.demo.app\n"
"\n"
"import com.demo.model.Money\n"
"import com.demo.model.Ledger\n"
"\n"
"fun main() {\n"
" val m = Money(5)\n"
" val l = Ledger()\n"
"}\n"
),
}
def test_kotlin_imports_resolve_to_real_nodes(tmp_path):
r = _extract(tmp_path, _IMPORT_CORPUS)
node_ids = {n["id"] for n in r["nodes"]}
main_file = _find(r, "Main.kt")
money = _find(r, "Money")
ledger = _find(r, "Ledger")
imports = _edges(r, "imports")
assert (main_file, money) in imports
assert (main_file, ledger) in imports
# Every Kotlin import edge points at an EXISTING node — the dangling
# bare-last-segment target ("money") would be pruned by build.
kotlin_imports = [e for e in r["edges"] if e["relation"] == "imports"
and str(e.get("source_file", "")).endswith(".kt")]
assert len(kotlin_imports) >= 2
for e in kotlin_imports:
assert e["target"] in node_ids, f"import target {e['target']} dangles"
def test_kotlin_import_evidence_promotes_calls_to_extracted(tmp_path):
r = _extract(tmp_path, _IMPORT_CORPUS)
main_fn = _find(r, "main()")
money = _find(r, "Money")
call = next(e for e in r["edges"] if e["relation"] == "calls"
and e["source"] == main_fn and e["target"] == money)
assert call["confidence"] == "EXTRACTED", \
"an explicitly-imported cross-file call must be promoted to EXTRACTED"
def test_kotlin_wildcard_import_emits_no_symbol_edge(tmp_path):
r = _extract(tmp_path, {
**{k: v for k, v in _IMPORT_CORPUS.items() if k != "app/Main.kt"},
"app/Main.kt": (
"package com.demo.app\n"
"\n"
"import com.demo.model.*\n"
"\n"
"fun main() { }\n"
),
})
main_file = _find(r, "Main.kt")
bad_targets = {"model", "*", ""}
for e in r["edges"]:
if e["relation"] == "imports" and e["source"] == main_file:
assert e["target"] not in bad_targets, \
"a wildcard import names a PACKAGE; a symbol-level edge to the " \
"last segment is a phantom"
def test_kotlin_aliased_import_resolves_to_original_symbol(tmp_path):
r = _extract(tmp_path, {
**{k: v for k, v in _IMPORT_CORPUS.items() if k != "app/Main.kt"},
"app/Main.kt": (
"package com.demo.app\n"
"\n"
"import com.demo.model.Money as Cash\n"
"\n"
"fun main() {\n"
" val m = Cash(5)\n"
"}\n"
),
})
main_file = _find(r, "Main.kt")
money = _find(r, "Money")
assert (main_file, money) in _edges(r, "imports"), \
"`import a.b.C as D` still imports C — the alias is caller-local"
alias_edge = next(e for e in r["edges"] if e["relation"] == "imports"
and e["source"] == main_file and e["target"] == money)
meta = alias_edge.get("metadata") or {}
assert meta.get("target_fqn") == "com.demo.model.Money"
assert meta.get("alias") == "Cash"
# ── #2550: fully-qualified call expressions ──────────────────────────────────
_FQ_CORPUS = {
"lib/Lib.kt": (
"package com.demo.lib\n"
"\n"
"fun BetaScreen() { }\n"
"\n"
"object Help {\n"
" fun help() { }\n"
"}\n"
),
"feature/Feature.kt": (
"package com.demo.feature\n"
"\n"
"fun DeltaScreen() { }\n"
"\n"
"fun SamePackageCaller() {\n"
" com.demo.feature.DeltaScreen()\n"
"}\n"
),
"nav/Nav.kt": (
"package com.demo.nav\n"
"\n"
"fun NavGraph() {\n"
" com.demo.lib.BetaScreen()\n"
" com.demo.feature.DeltaScreen()\n"
" com.demo.lib.Help.help()\n"
" com.nonexistent.pkg.Thing()\n"
"}\n"
),
}
def test_kotlin_fully_qualified_calls_resolve(tmp_path):
r = _extract(tmp_path, _FQ_CORPUS)
calls = _edges(r, "calls")
navgraph = _find(r, "NavGraph()")
beta = _find(r, "BetaScreen()")
delta = _find(r, "DeltaScreen()")
same_pkg = _find(r, "SamePackageCaller()")
help_fn = _find(r, ".help()")
assert (navgraph, beta) in calls
assert (navgraph, delta) in calls
assert (same_pkg, delta) in calls
assert (navgraph, help_fn) in calls, \
"`com.demo.lib.Help.help()` must resolve through the object declaration"
fq_calls = [e for e in r["edges"] if e["relation"] == "calls"
and e["source"] == navgraph]
assert all(e["confidence"] == "EXTRACTED" for e in fq_calls), \
"the FQN is written verbatim in source: exact match, EXTRACTED"
def test_kotlin_fq_call_to_unknown_package_yields_no_edge(tmp_path):
r = _extract(tmp_path, _FQ_CORPUS)
navgraph = _find(r, "NavGraph()")
targets = {t for s, t in _edges(r, "calls") if s == navgraph}
assert not any("thing" in t.lower() for t in targets), \
"`com.nonexistent.pkg.Thing()` is external — no edge, no fabricated node"
def test_kotlin_fq_call_to_ambiguous_name_yields_no_edge(tmp_path):
r = _extract(tmp_path, {
"dup1/D1.kt": (
"package com.demo.dup\n"
"\n"
"fun Same() { }\n"
),
"dup2/D2.kt": (
"package com.demo.dup\n"
"\n"
"fun Same() { }\n"
),
"callr/Caller.kt": (
"package com.demo.callr\n"
"\n"
"fun Caller() {\n"
" com.demo.dup.Same()\n"
"}\n"
),
})
caller = _find(r, "Caller()")
assert not {t for s, t in _edges(r, "calls") if s == caller}, \
"`Same` is defined twice in com.demo.dup — the exactly-one-candidate " \
"guard must refuse to pick"
# ── #2551: one-line type bodies + ERROR recovery ─────────────────────────────
def test_kotlin_partial_parse_warns_with_file_and_line(tmp_path, capsys):
# Consecutive one-line class bodies dissolve the whole file's parse in
# tree-sitter-kotlin 1.x; graphify must say so instead of silently
# returning a near-empty result.
files = {
"Broken.kt": (
"class A { val v: Money = Money(5) }\n"
"class B { val w: Ledger = Ledger() }\n"
"fun Top() { }\n"
),
}
_extract(tmp_path, files)
err = capsys.readouterr().err
assert "syntax errors" in err
assert "Broken.kt" in err
assert re.search(r"first error at line \d+", err)
# The marker must survive the per-file AST cache: a warm re-run (same
# cache_root) warns again.
old = os.getcwd()
try:
os.chdir(tmp_path)
extract([Path("Broken.kt")], cache_root=tmp_path / ".cache", parallel=False)
finally:
os.chdir(old)
err = capsys.readouterr().err
assert "syntax errors" in err and "Broken.kt" in err
def test_kotlin_one_line_class_with_fun_still_extracts(tmp_path, capsys):
# `class VM { fun f() = 1 }` trips has_error but recovers structurally:
# everything must extract, and the warning names the file.
r = _extract(tmp_path, {
"VM.kt": (
"class VM { fun f() = 1 }\n"
"fun After() { }\n"
),
})
vm = _find(r, "VM")
f = _find(r, ".f()")
_find(r, "After()") # present
assert (vm, f) in _edges(r, "method")
assert "VM.kt" in capsys.readouterr().err
def test_kotlin_one_line_class_keeps_field_reference(tmp_path):
# ERROR parent-link guard: the one-line body's property must keep its
# enclosing class, so the field-type reference lands on C.
r = _extract(tmp_path, {
"C.kt": "class C { val v: Money = Money(5) }\n",
})
c = _find(r, "C")
money = _find(r, "Money")
field_refs = {(e["source"], e["target"]) for e in r["edges"]
if e["relation"] == "references" and e.get("context") == "field"}
assert (c, money) in field_refs
# ── keep-the-bar: multi-line Kotlin is byte-identical ────────────────────────
def test_multiline_kotlin_unchanged(tmp_path, capsys):
"""Golden guard: ordinary multi-line Kotlin produces the same nodes/edges
as before the #2526/#2550/#2551 handling is purely additive — and no
partial-parse warning fires."""
r = _extract(tmp_path, {
"Shop.kt": (
"package com.shop\n"
"\n"
"class Cart {\n"
" val items: Inventory = Inventory()\n"
" fun checkout() {\n"
" total()\n"
" }\n"
" fun total() { }\n"
"}\n"
"\n"
"class Inventory\n"
"\n"
"fun main() {\n"
" Cart().checkout()\n"
"}\n"
),
})
labels = {n["label"] for n in r["nodes"]}
assert {"Shop.kt", "Cart", "Inventory", ".checkout()", ".total()",
"main()"} <= labels
cart = _find(r, "Cart")
checkout = _find(r, ".checkout()")
total = _find(r, ".total()")
methods = _edges(r, "method")
assert (cart, checkout) in methods and (cart, total) in methods
assert (checkout, total) in _edges(r, "calls")
inv = _find(r, "Inventory")
field_refs = {(e["source"], e["target"]) for e in r["edges"]
if e["relation"] == "references" and e.get("context") == "field"}
assert (cart, inv) in field_refs
assert "syntax errors" not in capsys.readouterr().err
+77 -1
View File
@@ -25,7 +25,8 @@ def _calls(tmp_path, files: dict[str, str]):
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")
r = extract([tmp_path / n for n in files],
cache_root=tmp_path / "graphify-out", parallel=False)
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
@@ -79,3 +80,78 @@ def test_array_typed_receiver_emits_no_edge(tmp_path):
"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)
# ── Origin gate (#2553) ──────────────────────────────────────────────────────
# A receiver typed as a THIRD-PARTY `Repo` must never bind, by name alone, to an
# unrelated local `class Repo` the caller's file neither defines nor imports.
_LOCAL_REPO = "export class Repo {\n save(): void {}\n static staticSave(): void {}\n}\n"
def _cross_file_edges(r, src_file: str, tgt_file: str):
"""Edges (any relation) whose source node lives in src_file and target in tgt_file."""
sf = {n["id"]: str(n.get("source_file", "")) for n in r["nodes"]}
# method nodes carry their own source_file; fall back to it for both ends
return [e for e in r["edges"]
if sf.get(e["source"], "").endswith(src_file)
and sf.get(e["target"], "").endswith(tgt_file)]
def test_third_party_type_does_not_fabricate_edge_to_local_class(tmp_path):
_, 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"),
})
bad = [e for e in _cross_file_edges(r, "filea.ts", "fileb.ts")
if e["relation"] in ("calls", "references", "indirect_call")]
assert not bad, f"fabricated cross-file edge(s) to un-imported local Repo: {bad}"
def test_genuinely_imported_type_still_resolves_inferred(tmp_path):
_, r = _calls(tmp_path, {
"fileb.ts": _LOCAL_REPO,
"filea.ts": ('import { Repo } from "./fileb";\n'
"export class ReportService {\n"
" constructor(private repo: Repo) {}\n"
" run(): void { this.repo.save(); }\n"
"}\n"),
})
lbl = {n["id"]: n["label"] for n in r["nodes"]}
hits = [e for e in r["edges"]
if e["relation"] == "calls"
and "run" in lbl.get(e["source"], "") and "save" in lbl.get(e["target"], "")]
assert hits, "imported receiver type must still resolve"
# table-inferred receiver -> INFERRED (Swift/C#/Java tiering parity)
assert all(e["confidence"] == "INFERRED" for e in hits)
def test_source_qualified_static_call_is_extracted(tmp_path):
_, r = _calls(tmp_path, {
"fileb.ts": _LOCAL_REPO,
"filea.ts": ('import { Repo } from "./fileb";\n'
"export class ReportService {\n"
" constructor(private repo: Repo) {}\n"
" run(): void { Repo.staticSave(); }\n"
"}\n"),
})
lbl = {n["id"]: n["label"] for n in r["nodes"]}
hits = [e for e in r["edges"]
if e["relation"] == "calls"
and "run" in lbl.get(e["source"], "")
and "staticSave" in lbl.get(e["target"], "")]
assert hits, "source-qualified Repo.staticSave() must resolve"
assert all(e["confidence"] == "EXTRACTED" for e in hits)
def test_same_file_type_still_resolves(tmp_path):
calls, _ = _calls(tmp_path, {
"one.ts": (_LOCAL_REPO
+ "const r = new Repo();\n"
"export function runLocal(): void { r.save(); }\n"),
})
assert any("runLocal" in s and "save" in t for s, t in calls)