Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2107,7 +2107,16 @@ def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_
)


def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> list[tuple[str, str]]:
"""Emit module-level `imports` edges and report the imported modules.

A Swift `import CoreKit` names a module, not a file path, so — unlike the
file-resolving JS/TS handlers — there is no existing node for the edge to
point at. The returned (id, label) pairs let the extractor materialize a
`type=module` anchor node so the edge survives; without it build_from_json
prunes every Swift import edge as a dangling/external reference (#1327).
"""
modules: list[tuple[str, str]] = []
for child in node.children:
if child.type == "identifier":
raw = _read_text(child, source)
Expand All @@ -2122,7 +2131,9 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
modules.append((tgt_nid, raw))
break
return modules


def _read_csharp_type_name(node, source: bytes) -> str | None:
Expand Down Expand Up @@ -2219,16 +2230,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if config.ts_module == "tree_sitter_swift":
swift_protocol_names, swift_class_names = _swift_pre_scan(root, source)

def add_node(nid: str, label: str, line: int) -> None:
def add_node(nid: str, label: str, line: int, *, node_type: str | None = None) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
node = {
"id": nid,
"label": label,
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
}
if node_type:
node["type"] = node_type
nodes.append(node)

def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
Expand Down Expand Up @@ -2264,7 +2278,16 @@ def walk(node, parent_class_nid: str | None = None) -> None:
# Import types
if t in config.import_types:
if config.import_handler:
config.import_handler(node, source, file_nid, stem, edges, str_path)
imported_modules = config.import_handler(node, source, file_nid, stem, edges, str_path)
# Module-level import handlers (Swift) name a module, not a file
# path, so there is no pre-existing node to anchor the edge to.
# They return (id, label) pairs for which we materialize a
# `type=module` node; otherwise build_from_json prunes every such
# import edge as a dangling/external reference (#1327).
if imported_modules:
line = node.start_point[0] + 1
for mod_nid, mod_label in imported_modules:
add_node(mod_nid, mod_label, line, node_type="module")
# For export_statement: only return (skip children) if it's a re-export
# (has a `from` source). Otherwise fall through to walk children which may
# contain function_declaration, class_declaration, etc.
Expand Down Expand Up @@ -6992,9 +7015,18 @@ def _disambiguate_colliding_node_ids(
raw_calls: list[dict],
root: Path,
) -> None:
"""Rewrite only colliding node IDs, using source path as the disambiguator."""
"""Rewrite only colliding node IDs, using source path as the disambiguator.

Module anchor nodes (#1327) are exempt: `import CoreKit` from three files
yields three `type=module` nodes with the same id but different source_files.
Those are the *same* module, not distinct same-named symbols, so they must
collapse to one shared node — disambiguating them by path would scatter a
single module across N file-qualified duplicates.
"""
by_id: dict[str, list[dict]] = {}
for node in nodes:
if node.get("type") == "module":
continue
nid = node.get("id")
if isinstance(nid, str) and nid:
by_id.setdefault(nid, []).append(node)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,35 @@ def test_swift_no_dangling_edges():
for e in r["edges"]:
assert e["source"] in node_ids


def test_swift_import_targets_resolve_to_nodes():
# #1327: every Swift `imports` edge must point at a node that exists, or
# build_from_json prunes it as a dangling/external reference.
r = extract_swift(FIXTURES / "sample.swift")
node_ids = {n["id"] for n in r["nodes"]}
import_edges = _edges_with_relation(r, "imports")
assert import_edges
for e in import_edges:
assert e["target"] in node_ids


def test_swift_imports_create_module_nodes():
# #1327: each imported module gets a `type=module` anchor node.
r = extract_swift(FIXTURES / "sample.swift")
module_labels = {n["label"] for n in r["nodes"] if n.get("type") == "module"}
assert {"Foundation", "UIKit"} <= module_labels


def test_swift_import_edges_survive_build():
# #1327: import edges must remain after graph assembly, not just extraction.
from graphify.build import build_from_json
r = extract_swift(FIXTURES / "sample.swift")
G = build_from_json(r, directed=True)
import_edges = [
(u, v) for u, v, d in G.edges(data=True) if d.get("relation") == "imports"
]
assert import_edges

def test_swift_finds_actor():
r = extract_swift(FIXTURES / "sample.swift")
assert any("CacheManager" in l for l in _labels(r))
Expand Down
85 changes: 85 additions & 0 deletions tests/test_swift_import_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

from pathlib import Path

from graphify.build import build_from_json
from graphify.extract import extract


def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path


def _module_nodes(result: dict, label: str) -> list[dict]:
return [
n for n in result["nodes"]
if n.get("type") == "module" and n.get("label") == label
]


def _import_edges(result: dict) -> list[dict]:
return [e for e in result["edges"] if e.get("relation") == "imports"]


def test_swift_import_resolves_to_module_node(tmp_path: Path):
# #1327: `import CoreKit` must anchor to a node, or build_from_json prunes
# the edge as a dangling/external reference.
core = _write(tmp_path / "Sources/CoreKit/CoreKit.swift", "public struct CoreKit {}\n")
feature = _write(
tmp_path / "Sources/FeatureKit/FeatureKit.swift",
"import CoreKit\n\npublic struct FeatureKit {}\n",
)

result = extract([core, feature], cache_root=tmp_path)

node_ids = {n["id"] for n in result["nodes"]}
imports = _import_edges(result)
assert imports
for e in imports:
assert e["target"] in node_ids
assert _module_nodes(result, "CoreKit")


def test_swift_same_module_imported_twice_collapses_to_one_node(tmp_path: Path):
# #1327: the same module imported from multiple files is ONE module, not N
# file-qualified duplicates — collision-disambiguation must exempt modules.
core = _write(tmp_path / "Sources/CoreKit/CoreKit.swift", "public struct CoreKit {}\n")
a = _write(
tmp_path / "Sources/AKit/AKit.swift",
"import CoreKit\n\npublic struct AKit {}\n",
)
b = _write(
tmp_path / "Sources/BKit/BKit.swift",
"import CoreKit\n\npublic struct BKit {}\n",
)

result = extract([core, a, b], cache_root=tmp_path)

# Each importing file contributes a module-node dict, but they must share a
# single id (NOT be split into path-qualified duplicates) so build_from_json
# collapses them into one shared node.
core_modules = _module_nodes(result, "CoreKit")
module_ids = {n["id"] for n in core_modules}
assert len(module_ids) == 1
# Both importers point at that single shared module id.
import_targets = {e["target"] for e in _import_edges(result)}
assert import_targets == module_ids


def test_swift_import_edges_survive_build(tmp_path: Path):
# #1327: edges must remain after graph assembly, deduped to one module node.
core = _write(tmp_path / "Sources/CoreKit/CoreKit.swift", "public struct CoreKit {}\n")
a = _write(tmp_path / "Sources/AKit/AKit.swift", "import CoreKit\n")
b = _write(tmp_path / "Sources/BKit/BKit.swift", "import CoreKit\n")

result = extract([core, a, b], cache_root=tmp_path)
G = build_from_json(result, directed=True)

import_edges = [
(u, v) for u, v, d in G.edges(data=True) if d.get("relation") == "imports"
]
assert len(import_edges) == 2
# Both edges land on the same CoreKit module node.
assert len({v for _, v in import_edges}) == 1