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)