mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 01:06:36 +00:00
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:
co-authored by
Claude Opus 4.8
parent
3d19463484
commit
cfc6a75c86
+379
-40
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user