diff --git a/CHANGELOG.md b/CHANGELOG.md index 076d602c8..6af312d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feat: Swift extraction now preserves overload identity and resolves literal calls by signature; models protocol method/property requirements, associated types, type aliases, and concrete requirement implementations; follows typed property and factory-return call chains; and extracts Swift Package Manager manifests into package, product, target, dependency, and source-membership structure. Existing non-overloaded symbol IDs remain stable, ambiguous receivers still fail closed, and all extraction remains local/offline. + ## 0.9.15 (2026-07-13) - Fix: detection now honors nested `.gitignore`/`.graphifyignore` files below the scan root, not just those at the scan root and above (#1847, thanks @Mohak-Agrawal). git applies a `.gitignore` to everything under its own directory, but graphify only loaded ignore files from the VCS-root-down-to-scan-root chain — so a `vendor/sub/.gitignore` deeper in the tree was never read and its exclusions leaked into the graph. Each directory's own ignore files are now read during the walk and anchored to that directory, preserving last-match-wins precedence (nearer files win, including over `.git/info/exclude`) and the parent-exclusion rule. diff --git a/README.md b/README.md index 0a883e75b..d6b9da118 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | -| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub | +| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` `Package.swift` — canonical package/dependency nodes; SwiftPM also maps products, package-qualified targets, internal target dependencies, and accepted source membership | | Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` (markdown `[text](./other.md)` links and `[[wikilinks]]` become `references` edges between docs) | | Office | `.docx .xlsx` (requires `uv tool install graphifyy[office]`) | | Google Workspace | `.gdoc .gsheet .gslides` (opt-in; requires `gws` auth and `--google-workspace`; Sheets need `uv tool install graphifyy[google]`) | diff --git a/graphify/extract.py b/graphify/extract.py index a4ac6cbbd..ecff38693 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9,7 +9,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, cast from .cache import load_cached, save_cached from .mcp_ingest import extract_mcp_config, is_mcp_config_path @@ -132,12 +132,14 @@ ) from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_member_type_table, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 +from graphify.extractors.engine import _swift_overload_match # noqa: E402,F401 from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401 from graphify.extractors.objc import _objc_local_var_types, extract_objc # noqa: E402,F401 from graphify.extractors.julia import extract_julia # noqa: E402,F401 +from graphify.extractors.swiftpm import extract_swift_package_manifest, link_swift_package_sources # noqa: E402,F401 _RECURSION_LIMIT = 10_000 @@ -886,7 +888,10 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st _SWIFT_CONFIG = LanguageConfig( ts_module="tree_sitter_swift", class_types=frozenset({"class_declaration", "protocol_declaration"}), - function_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}), + function_types=frozenset({ + "function_declaration", "protocol_function_declaration", + "init_declaration", "deinit_declaration", "subscript_declaration", + }), import_types=frozenset({"import_declaration"}), call_types=frozenset({"call_expression"}), call_function_field="", @@ -894,7 +899,10 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st call_accessor_field="", name_fallback_child_types=("simple_identifier", "type_identifier", "user_type"), body_fallback_child_types=("class_body", "protocol_body", "function_body", "enum_class_body"), - function_boundary_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}), + function_boundary_types=frozenset({ + "function_declaration", "protocol_function_declaration", + "init_declaration", "deinit_declaration", "subscript_declaration", + }), import_handler=_import_swift, ) @@ -1663,7 +1671,17 @@ def extract_lua(path: Path) -> dict: def extract_swift(path: Path) -> dict: """Extract classes, structs, protocols, functions, imports, and calls from a .swift file.""" - return _extract_generic(path, _SWIFT_CONFIG) + result = _extract_generic(path, _SWIFT_CONFIG) + result["language"] = "swift" + result["language_family"] = "native" + for item in ( + result.get("nodes", []) + + result.get("edges", []) + + result.get("raw_calls", []) + ): + item.setdefault("language", "swift") + item.setdefault("language_family", "native") + return result # ── Julia extractor (custom walk) ──────────────────────────────────────────── @@ -1968,12 +1986,26 @@ def _merge_swift_extensions( if not extension_nids: return + contained = { + edge.get("target") + for edge in all_edges + if edge.get("relation") == "contains" + } label_to_canonical: dict[str, list[str]] = {} for n in all_nodes: if n.get("id") in extension_nids: continue label = n.get("label") - if not label: + if ( + not label + or not n.get("source_file") + or n.get("id") not in contained + or not _is_type_like_definition(n) + or n.get("type") in { + "file", "module", "package", "product", "property", + "associated_type", "type_alias", + } + ): continue label_to_canonical.setdefault(label, []).append(n["id"]) @@ -2038,8 +2070,6 @@ def _resolve_swift_member_calls( tt = result.get("swift_type_table") if tt and tt.get("path"): type_table_by_file[tt["path"]] = tt.get("table", {}) - if not type_table_by_file: - return def _key(label: str) -> str: return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() @@ -2056,50 +2086,245 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if ( + n.get("source_file") + and n.get("id") in contained + and _is_type_like_definition(n) + and n.get("type") not in { + "file", "module", "package", "product", "property", + "protocol_requirement", "protocol_property_requirement", + "associated_type", "type_alias", + } + ): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) - # (type_node_id, method_key) -> method_node_id, from `method` edges. - method_index: dict[tuple[str, str], str] = {} + # (type_node_id, method_key) -> method node ids. Multiple entries are valid + # for overloads and are selected from recorded call/signature facts below. + method_index: dict[tuple[str, str], list[str]] = {} for e in all_edges: if e.get("relation") != "method": continue - src, tgt = e.get("source"), e.get("target") + src, tgt = str(e.get("source", "")), str(e.get("target", "")) + if not src or not tgt: + continue tnode = node_by_id.get(tgt) if tnode is not None: - method_index[(src, _key(tnode.get("label", "")))] = tgt + method_index.setdefault((src, _key(tnode.get("label", ""))), []).append(tgt) + + top_level_functions: dict[str, list[str]] = {} + for edge in all_edges: + if edge.get("relation") != "contains": + continue + target_id = str(edge.get("target", "")) + target = node_by_id.get(target_id) + label = str(target.get("label", "")) if target else "" + if ( + target + and target.get("language") == "swift" + and label.endswith("()") + and not label.startswith(".") + ): + top_level_functions.setdefault(_key(label), []).append(target_id) + + # Walk deep property chains such as self.root.container.service.fetch(). + property_types: dict[tuple[str, str], str] = {} + for edge in all_edges: + if edge.get("relation") != "defines": + continue + prop = node_by_id.get(edge.get("target")) + metadata = prop.get("metadata", {}) if prop else {} + declared = metadata.get("declared_type") if isinstance(metadata, dict) else None + if prop and prop.get("type") == "property" and declared: + property_types[(str(edge.get("source")), str(prop.get("label")))] = str( + declared + ) + + owner_by_method = { + str(edge.get("target")): str(edge.get("source")) + for edge in all_edges + if edge.get("relation") == "method" + } all_raw_calls: list[dict] = [] for result in per_file: all_raw_calls.extend(result.get("raw_calls", [])) existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + + def _select_candidates( + candidates: list[str], + facts: dict, + static_dispatch: bool | None = None, + ) -> str | None: + if static_dispatch is not None: + candidates = [ + nid + for nid in candidates + if bool( + (node_by_id.get(nid, {}).get("metadata") or {}).get("is_static") + ) + == static_dispatch + ] + if len(candidates) == 1: + return candidates[0] + matches = [ + nid + for nid in candidates + if (node := node_by_id.get(nid)) is not None + and _swift_overload_match(node, facts) + ] + return matches[0] if len(matches) == 1 else None + + def _select_method( + type_nid: str, + callee_key: str, + facts: dict, + static_dispatch: bool | None = None, + ) -> str | None: + return _select_candidates( + method_index.get((type_nid, callee_key), []), + facts, + static_dispatch, + ) + + def _select_top_level(callee_key: str, facts: dict) -> str | None: + return _select_candidates(top_level_functions.get(callee_key, []), facts) + + def _type_nid(type_name: str | None) -> str | None: + if not type_name: + return None + head = str(type_name).rstrip("?").split("<", 1)[0].split(".")[-1] + definitions = type_def_nids.get(_key(head), []) + return definitions[0] if len(definitions) == 1 else None + + def _walk_receiver_type( + rc: dict, + caller: str, + ) -> tuple[str | None, bool, bool]: + path = list(rc.get("receiver_path") or []) + type_qualified = False + current: str | None = None + if path: + head = path.pop(0) + if head == "self": + current = owner_by_method.get(caller) + elif head[:1].isupper(): + current = _type_nid(head) + type_qualified = True + else: + current = _type_nid( + rc.get("receiver_type") + or type_table_by_file.get(rc.get("source_file", ""), {}).get(head) + ) + traversed_property = bool(path) + for segment in path: + if current is None: + break + current = _type_nid(property_types.get((current, segment))) + if current is not None: + return current, type_qualified, type_qualified and not traversed_property + + # Resolve one factory call and use its declared return type for the outer + # member call: Factory.make().fetch(). + inner = rc.get("receiver_call") or {} + if inner: + inner_path = list(inner.get("receiver_path") or []) + inner_type: str | None = None + if inner_path and inner_path[0][:1].isupper(): + inner_type = _type_nid(inner_path[0]) + type_qualified = True + elif inner_path: + inner_type = _type_nid( + inner.get("receiver_type") + or type_table_by_file.get(rc.get("source_file", ""), {}).get(inner_path[0]) + ) + if inner_type: + inner_method = _select_method( + inner_type, + _key(inner.get("callee", "")), + inner, + static_dispatch=type_qualified, + ) + if inner_method: + metadata = node_by_id.get(inner_method, {}).get("metadata", {}) + returned = ( + metadata.get("return_type") + if isinstance(metadata, dict) + else None + ) + resolved = _type_nid(returned) + if resolved: + return resolved, type_qualified, False + elif not inner_path: + inner_function = _select_top_level( + _key(inner.get("callee", "")), + inner, + ) + if inner_function: + metadata = node_by_id.get(inner_function, {}).get("metadata", {}) + returned = ( + metadata.get("return_type") + if isinstance(metadata, dict) + else None + ) + resolved = _type_nid(returned) + if resolved: + return resolved, False, False + + receiver = rc.get("receiver") + if receiver: + if str(receiver)[:1].isupper(): + return _type_nid(str(receiver)), True, True + return _type_nid( + rc.get("receiver_type") + or type_table_by_file.get(rc.get("source_file", ""), {}).get(str(receiver)) + ), False, False + return None, False, False + for rc in all_raw_calls: + if rc.get("lang") == "swift" and not rc.get("is_member_call"): + callee = rc.get("callee") + caller = rc.get("caller_nid") + target = _select_top_level(_key(callee or ""), rc) + if not caller or not target or caller == target: + continue + if (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append({ + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + "language": "swift", + "language_family": "native", + }) + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") callee = rc.get("callee") - if not receiver or not callee: - continue - # Determine the receiver's type. An upper-cased receiver is itself a type - # (Type.staticMethod(), Singleton.shared.x()); otherwise look it up in the - # declaring file's local type table. - if receiver[:1].isupper(): - type_name = receiver - type_qualified = True - else: - type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) - type_qualified = False - if not type_name: - continue - type_defs = type_def_nids.get(_key(type_name), []) - if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + if not callee or not ( + receiver or rc.get("receiver_path") or rc.get("receiver_call") + ): continue - type_nid = type_defs[0] caller = rc.get("caller_nid") if not caller: continue - method_nid = method_index.get((type_nid, _key(callee))) + type_nid, type_qualified, static_dispatch = _walk_receiver_type(rc, caller) + if not type_nid: + continue + method_nid = _select_method( + type_nid, + _key(callee), + rc, + static_dispatch=static_dispatch, + ) target = method_nid or type_nid relation = "calls" if method_nid else "references" if target == caller or (caller, target) in existing_pairs: @@ -2122,6 +2347,217 @@ def _key(label: str) -> str: }) +def _resolve_swift_protocol_requirements( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Link concrete Swift members to the protocol requirements they satisfy.""" + del per_file + node_by_id = {str(n.get("id")): n for n in all_nodes if n.get("id")} + + # File-local parsing cannot know that the first unknown base of a class is a + # protocol declared elsewhere. Once stubs have been rewired to final nodes, + # correct that relationship before discovering conformances. + for edge in all_edges: + if edge.get("relation") != "inherits": + continue + source = node_by_id.get(str(edge.get("source", "")), {}) + target = node_by_id.get(str(edge.get("target", "")), {}) + if ( + source.get("language") == "swift" + and source.get("type") != "protocol" + and target.get("type") == "protocol" + ): + edge["relation"] = "implements" + + members_by_owner: dict[str, list[str]] = {} + properties_by_owner: dict[str, list[str]] = {} + aliases_by_owner: dict[str, dict[str, str]] = {} + for edge in all_edges: + src, tgt = str(edge.get("source", "")), str(edge.get("target", "")) + target = node_by_id.get(tgt) + if not target: + continue + if edge.get("relation") == "method": + members_by_owner.setdefault(src, []).append(tgt) + elif edge.get("relation") == "defines" and target.get("type") in ( + "property", + "protocol_property_requirement", + ): + properties_by_owner.setdefault(src, []).append(tgt) + elif edge.get("relation") == "defines" and target.get("type") == "type_alias": + metadata = target.get("metadata") or {} + declared = metadata.get("declared_type") if isinstance(metadata, dict) else None + if declared: + aliases_by_owner.setdefault(src, {})[str(target.get("label", ""))] = str( + declared + ) + + def _substitute_type(type_name: object, aliases: dict[str, str]) -> str: + value = str(type_name or "") + for alias, concrete in aliases.items(): + value = re.sub(rf"\b{re.escape(alias)}\b", concrete, value) + return re.sub(r"\s+", "", value).rstrip("?") + + conformances = [ + (str(edge.get("source")), str(edge.get("target")), edge) + for edge in all_edges + if edge.get("relation") == "implements" + and node_by_id.get(str(edge.get("target")), {}).get("type") == "protocol" + ] + protocol_bases: dict[str, list[str]] = {} + for edge in all_edges: + if edge.get("relation") != "inherits": + continue + source = str(edge.get("source", "")) + target = str(edge.get("target", "")) + if ( + node_by_id.get(source, {}).get("type") == "protocol" + and node_by_id.get(target, {}).get("type") == "protocol" + ): + protocol_bases.setdefault(source, []).append(target) + + def _protocol_lineage(protocol: str) -> list[str]: + seen: set[str] = set() + pending = [protocol] + while pending: + current = pending.pop() + if current in seen: + continue + seen.add(current) + pending.extend(protocol_bases.get(current, [])) + return sorted(seen) + + existing = { + (edge.get("source"), edge.get("target"), edge.get("relation")) + for edge in all_edges + } + + for concrete_type, protocol, conformance in conformances: + aliases = aliases_by_owner.get(concrete_type, {}) + concrete_methods = members_by_owner.get(concrete_type, []) + protocol_ids = _protocol_lineage(protocol) + requirement_ids = [ + requirement_id + for protocol_id in protocol_ids + for requirement_id in members_by_owner.get(protocol_id, []) + ] + for requirement_id in requirement_ids: + requirement = node_by_id.get(requirement_id, {}) + if requirement.get("type") != "protocol_requirement": + continue + req_metadata = requirement.get("metadata", {}) + facts = { + "argument_count": req_metadata.get("arity"), + "argument_labels": req_metadata.get("parameter_labels", []), + "argument_types": [ + _substitute_type(type_name, aliases) + for type_name in req_metadata.get("parameter_types", []) + ], + } + candidates = [ + method_id + for method_id in concrete_methods + if node_by_id.get(method_id, {}).get("label") + == requirement.get("label") + and _swift_overload_match(node_by_id[method_id], facts) + ] + candidates = [ + method_id + for method_id in candidates + if ( + (candidate_meta := node_by_id[method_id].get("metadata") or {}) + and bool(candidate_meta.get("is_static")) + == bool(req_metadata.get("is_static")) + and ( + bool(req_metadata.get("is_async")) + or not bool(candidate_meta.get("is_async")) + ) + and ( + bool(req_metadata.get("is_throwing")) + or not bool(candidate_meta.get("is_throwing")) + ) + and _substitute_type( + candidate_meta.get("return_type"), + {}, + ) + == _substitute_type(req_metadata.get("return_type"), aliases) + ) + ] + if len(candidates) != 1: + continue + edge_key = (candidates[0], requirement_id, "implements") + if edge_key in existing: + continue + existing.add(edge_key) + all_edges.append({ + "source": candidates[0], + "target": requirement_id, + "relation": "implements", + "context": "protocol_requirement", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": conformance.get("source_file", ""), + "source_location": conformance.get("source_location"), + "weight": 1.0, + "language": "swift", + "language_family": "native", + }) + + concrete_properties = properties_by_owner.get(concrete_type, []) + property_requirement_ids = [ + requirement_id + for protocol_id in protocol_ids + for requirement_id in properties_by_owner.get(protocol_id, []) + ] + for requirement_id in property_requirement_ids: + requirement = node_by_id.get(requirement_id, {}) + if requirement.get("type") != "protocol_property_requirement": + continue + matches = [ + property_id + for property_id in concrete_properties + if node_by_id.get(property_id, {}).get("label") + == requirement.get("label") + ] + req_metadata = requirement.get("metadata") or {} + matches = [ + property_id + for property_id in matches + if ( + (property_meta := node_by_id[property_id].get("metadata") or {}) + and _substitute_type(property_meta.get("declared_type"), {}) + == _substitute_type(req_metadata.get("declared_type"), aliases) + and bool(property_meta.get("is_static")) + == bool(req_metadata.get("is_static")) + and ( + not bool(req_metadata.get("is_settable")) + or bool(property_meta.get("is_settable")) + ) + ) + ] + if len(matches) != 1: + continue + edge_key = (matches[0], requirement_id, "implements") + if edge_key in existing: + continue + existing.add(edge_key) + all_edges.append({ + "source": matches[0], + "target": requirement_id, + "relation": "implements", + "context": "protocol_property_requirement", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": conformance.get("source_file", ""), + "source_location": conformance.get("source_location"), + "weight": 1.0, + "language": "swift", + "language_family": "native", + }) + + def _resolve_python_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -2751,6 +3187,13 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls) ) +register_language_resolver( + LanguageResolver( + "swift_protocol_requirements", + frozenset({".swift"}), + _resolve_swift_protocol_requirements, + ) +) register_language_resolver( LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls) ) @@ -3999,6 +4442,8 @@ def _is_cpp_header(path: Path) -> bool: def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" + if path.name == "Package.swift": + return extract_swift_package_manifest if path.name.lower().endswith(".blade.php"): return extract_blade # MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed @@ -4335,6 +4780,8 @@ def extract( if per_file[i] is None: per_file[i] = {"nodes": [], "edges": []} + link_swift_package_sources(cast(list[dict], per_file), paths) + # #1666: surface any source file an extractor accepted but that produced zero # nodes (not even a file node). Such a file is silently absent from the graph, # so affected/explain are blind to and through it with no other signal. @@ -4488,6 +4935,10 @@ def extract( e["source"] = id_remap[e["source"]] if e.get("target") in id_remap: e["target"] = id_remap[e["target"]] + for result in per_file: + for extension in result.get("swift_extensions", []) or []: + if extension.get("nid") in id_remap: + extension["nid"] = id_remap[extension["nid"]] if prefix_remap: sym_remap: dict[str, str] = {} for n in all_nodes: @@ -4532,6 +4983,10 @@ def extract( cn = rc.get("caller_nid") if cn in sym_remap: rc["caller_nid"] = sym_remap[cn] + for result in per_file: + for extension in result.get("swift_extensions", []) or []: + if extension.get("nid") in sym_remap: + extension["nid"] = sym_remap[extension["nid"]] _merge_swift_extensions(per_file, all_nodes, all_edges) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 90c95cc49..9d4e00afe 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3,6 +3,7 @@ import hashlib import importlib +import re from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text from graphify.extractors.models import LanguageConfig from graphify.extractors.resolution import _resolve_js_import_target @@ -804,13 +805,69 @@ def _swift_property_type_node(property_node): return c return None + +def _swift_property_declared_type(property_node, source: bytes) -> str: + annotation = _swift_property_type_node(property_node) + if annotation is None: + return "" + value = next((child for child in annotation.children if child.is_named), None) + return _read_text(value, source).strip() if value is not None else "" + + +def _swift_descendant_has_type(node, type_name: str) -> bool: + if node.type == type_name: + return True + return any(_swift_descendant_has_type(child, type_name) for child in node.children) + + +def _swift_property_is_settable(property_node) -> bool: + if _swift_descendant_has_type(property_node, "setter_specifier"): + return True + computed = next( + (child for child in property_node.children if child.type == "computed_property"), + None, + ) + if computed is not None: + return False + return any( + child.type == "value_binding_pattern" + and any(grandchild.type == "var" for grandchild in child.children) + for child in property_node.children + ) + + +def _swift_declaration_is_static(node, source: bytes) -> bool: + modifiers = " ".join( + _read_text(child, source) + for child in node.children + if child.type == "modifiers" + ) + return bool(re.search(r"\bstatic\b", modifiers)) or any( + not child.is_named and child.type == "class" for child in node.children + ) + + +def _swift_typealias_declared_type(node, source: bytes) -> str: + equals_seen = False + for child in node.children: + if not child.is_named and child.type == "=": + equals_seen = True + continue + if equals_seen and child.is_named: + return _read_text(child, source).strip() + return "" + + def _swift_property_name(property_node, source: bytes) -> str | None: """Return the bound name of a Swift property (``let x``/``var x = ...``).""" for c in property_node.children: if c.type == "pattern": - for sc in c.children: + stack = list(c.children) + while stack: + sc = stack.pop(0) if sc.type == "simple_identifier": return _read_text(sc, source) + stack[0:0] = list(sc.children) if c.type == "simple_identifier": return _read_text(c, source) return None @@ -852,6 +909,223 @@ def _swift_receiver_name(recv_node, source: bytes) -> str | None: return _read_text(sc, source) return None + +_SWIFT_FUNCTION_DECLARATIONS = frozenset({ + "function_declaration", "protocol_function_declaration", + "init_declaration", "deinit_declaration", "subscript_declaration", +}) + + +def _swift_parameter_type_node(parameter_node): + """Return a Swift parameter's type node across grammar field-name quirks.""" + colon_seen = False + for child in parameter_node.children: + if not child.is_named and child.type == ":": + colon_seen = True + continue + if colon_seen and child.is_named: + return child + return None + + +def _swift_function_facts(node, source: bytes, func_name: str) -> dict: + """Stable signature facts used for overload identity and call matching.""" + labels: list[str] = [] + local_names: list[str] = [] + parameter_types: list[str] = [] + for parameter in node.children: + if parameter.type != "parameter": + continue + simple_ids = [c for c in parameter.children if c.type == "simple_identifier"] + external = parameter.child_by_field_name("external_name") + local = parameter.child_by_field_name("name") + if local is not None and local.type != "simple_identifier": + local = None + if external is not None: + label = _read_text(external, source) + elif simple_ids: + label = _read_text(simple_ids[0], source) + else: + label = "_" + if local is not None: + local_name = _read_text(local, source) + elif len(simple_ids) >= 2: + local_name = _read_text(simple_ids[1], source) + elif simple_ids: + local_name = _read_text(simple_ids[0], source) + else: + local_name = "" + type_node = _swift_parameter_type_node(parameter) + labels.append(label or "_") + local_names.append(local_name) + parameter_types.append(_read_text(type_node, source).strip() if type_node else "") + + return_type = "" + arrow_seen = False + for child in node.children: + if not child.is_named and child.type == "->": + arrow_seen = True + continue + if arrow_seen and child.is_named: + return_type = _read_text(child, source).strip() + break + is_static = _swift_declaration_is_static(node, source) + is_async = any(c.type == "async" for c in node.children) + is_throwing = any(c.type in ("throws", "rethrows") for c in node.children) + signature = f"{func_name}({','.join(f'{label}:{ptype}' for label, ptype in zip(labels, parameter_types))})" + body = next( + ( + child + for child in node.children + if child.type in ("function_body", "computed_property") + ), + None, + ) + header_end = body.start_byte if body is not None else node.end_byte + declaration_signature = re.sub( + r"\s+", + " ", + source[node.start_byte:header_end].decode("utf-8", errors="replace").strip(), + ) + declaration_digest = hashlib.sha1( + declaration_signature.encode("utf-8") + ).hexdigest()[:12] + return { + "signature": signature, + "identity_signature": f"{signature}#{declaration_digest}", + "declaration_signature": declaration_signature, + "parameter_labels": labels, + "parameter_names": local_names, + "parameter_types": parameter_types, + "arity": len(labels), + "return_type": return_type, + "is_static": is_static, + "is_async": is_async, + "is_throwing": is_throwing, + "is_protocol_requirement": ( + node.type == "protocol_function_declaration" + or getattr(node, "parent", None) is not None + and node.parent.type == "protocol_body" + ), + } + + +def _swift_is_overloaded(node, source: bytes, func_name: str) -> bool: + parent = getattr(node, "parent", None) + if parent is None: + return False + count = 0 + for sibling in parent.children: + if sibling.type not in _SWIFT_FUNCTION_DECLARATIONS: + continue + if sibling.type == "init_declaration": + sibling_name = "init" + elif sibling.type == "deinit_declaration": + sibling_name = "deinit" + elif sibling.type == "subscript_declaration": + sibling_name = "subscript" + else: + name_node = sibling.child_by_field_name("name") + sibling_name = _read_text(name_node, source) if name_node is not None else "" + if sibling_name == func_name: + count += 1 + return count > 1 + + +def _swift_call_facts( + call_node, + source: bytes, + identifier_types: dict[str, str] | None = None, +) -> dict: + labels: list[str] = [] + argument_types: list[str] = [] + value_args = None + stack = list(call_node.children) + while stack: + current = stack.pop(0) + if current.type == "value_arguments": + value_args = current + break + stack[0:0] = list(current.children) + if value_args is not None: + for argument in value_args.children: + if argument.type != "value_argument": + continue + label_node = argument.child_by_field_name("name") + labels.append( + _read_text(label_node, source) if label_node is not None else "_" + ) + value = argument.child_by_field_name("value") + inferred = "" + if value is not None: + if value.type == "line_string_literal": + inferred = "String" + elif value.type == "integer_literal": + inferred = "Int" + elif value.type in ("real_literal", "float_literal"): + inferred = "Double" + elif value.type == "boolean_literal": + inferred = "Bool" + elif value.type == "call_expression": + inferred = _swift_constructor_type(value, source) or "" + elif value.type == "simple_identifier" and identifier_types: + inferred = identifier_types.get(_read_text(value, source), "") + argument_types.append(inferred) + + # Swift's trailing-closure syntax stores the closure directly under the + # call_suffix, outside value_arguments. Count it as the final unlabeled + # argument so `perform {}` cannot bind to a zero-arity overload. + suffix = next((c for c in call_node.children if c.type == "call_suffix"), None) + if suffix is not None: + trailing_closures = [ + child for child in suffix.children if child.type == "lambda_literal" + ] + for _closure in trailing_closures: + labels.append("_") + argument_types.append("") + return { + "argument_labels": labels, + "argument_types": argument_types, + "argument_count": len(labels), + } + + +def _swift_navigation_segments(node, source: bytes) -> list[str]: + """Flatten ``self.container.service`` into lexical receiver segments.""" + if node is None: + return [] + if node.type in ("simple_identifier", "self_expression"): + return [_read_text(node, source)] + if node.type != "navigation_expression": + return [] + target = node.child_by_field_name("target") or (node.children[0] if node.children else None) + out = _swift_navigation_segments(target, source) + suffix = node.child_by_field_name("suffix") + if suffix is None: + suffix = next((c for c in node.children if c.type == "navigation_suffix"), None) + if suffix is not None: + ident = next((c for c in suffix.children if c.type == "simple_identifier"), None) + if ident is not None: + out.append(_read_text(ident, source)) + return out + + +def _swift_overload_match(node: dict, call_facts: dict) -> bool: + metadata = node.get("metadata") or {} + if metadata.get("arity") != call_facts.get("argument_count"): + return False + declared_labels = list(metadata.get("parameter_labels") or []) + if declared_labels != list(call_facts.get("argument_labels") or []): + return False + declared_types = list(metadata.get("parameter_types") or []) + argument_types = list(call_facts.get("argument_types") or []) + for declared, actual in zip(declared_types, argument_types): + declared_base = re.sub(r"\s+", "", str(declared)).split("<", 1)[0].rstrip("?") + actual_base = re.sub(r"\s+", "", str(actual)).split("<", 1)[0].rstrip("?") + if actual_base and declared_base and declared_base != actual_base: + return False + return True + _C_PRIMITIVE_TYPE_NODES = frozenset({ "primitive_type", "sized_type_specifier", "auto", "placeholder_type_specifier", }) @@ -2158,6 +2432,10 @@ def _extract_generic( namespace_stack: list[str] = [] scope_stack: list[str] = [] function_bodies: list[tuple[str, object]] = [] + function_owner_by_nid: dict[str, str] = {} + swift_callable_name_counts: dict[tuple[str, str], int] = {} + swift_property_types_by_owner: dict[str, dict[str, str]] = {} + swift_receiver_types_by_caller: dict[str, dict[str, str]] = {} # nids of function / method / class definitions in this file. The indirect- # dispatch guard (Python) resolves a call-argument identifier to an edge only # when it names one of these callable defs — never an arbitrary same-named @@ -2322,9 +2600,23 @@ def walk(node, parent_class_nid: str | None = None) -> None: class_nid = _make_id(stem, ".".join(namespace_stack), class_name) line = node.start_point[0] + 1 metadata = None + class_node_type = None if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: metadata = {"is_nested_type": True} - add_node(class_nid, class_name, line, metadata=metadata) + if config.ts_module == "tree_sitter_swift": + swift_decl_kind = ( + "protocol" if t == "protocol_declaration" + else (_swift_declaration_keyword(node) or "class") + ) + class_node_type = swift_decl_kind + metadata = dict(metadata or {}, swift_kind=swift_decl_kind) + add_node( + class_nid, + class_name, + line, + node_type=class_node_type, + metadata=metadata, + ) callable_def_nids.add(class_nid) # a class is callable (constructor) add_edge(file_nid, class_nid, "contains", line) @@ -2973,11 +3265,100 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: add_edge(parent_class_nid, target_nid, "references", line, context=ctx) return + if (config.ts_module == "tree_sitter_swift" + and t in ("typealias_declaration", "associatedtype_declaration")): + name_node = node.child_by_field_name("name") + if name_node is None: + name_node = next( + (c for c in node.children if c.type == "type_identifier"), + None, + ) + if name_node is None: + return + name = _read_text(name_node, source) + line = node.start_point[0] + 1 + owner = parent_class_nid or file_nid + kind = "associatedtype" if t == "associatedtype_declaration" else "typealias" + nid = _make_id(owner, kind, name) + refs: list[tuple[str, str]] = [] + _swift_collect_type_refs(node, source, False, refs) + declared_types = [ref for ref, role in refs if role == "type" and ref != name] + alias_type = _swift_typealias_declared_type(node, source) + add_node( + nid, + name, + line, + node_type=( + "associated_type" if t == "associatedtype_declaration" else "type_alias" + ), + metadata=( + {"declared_type": alias_type or declared_types[0]} + if t == "typealias_declaration" and (alias_type or declared_types) + else None + ), + ) + add_edge(owner, nid, "defines" if parent_class_nid else "contains", line) + seen_refs: set[str] = set() + for ref_name, role in refs: + if ref_name == name or ref_name in seen_refs: + continue + seen_refs.add(ref_name) + target = ensure_named_node(ref_name, line) + add_edge( + nid, + target, + "references", + line, + context="generic_arg" if role == "generic_arg" else "type", + ) + return + + if (config.ts_module == "tree_sitter_swift" + and t == "protocol_property_declaration" + and parent_class_nid): + name = _swift_property_name(node, source) + if not name: + return + line = node.start_point[0] + 1 + nid = _make_id(parent_class_nid, "property_requirement", name) + refs: list[tuple[str, str]] = [] + _swift_collect_type_refs(node, source, False, refs) + declared_types = [ref for ref, _ in refs if ref != name] + declared_type = _swift_property_declared_type(node, source) + add_node( + nid, + name, + line, + node_type="protocol_property_requirement", + metadata={ + "declared_type": declared_type or (declared_types[0] if declared_types else ""), + "is_settable": _swift_property_is_settable(node), + "is_static": _swift_declaration_is_static(node, source), + }, + ) + add_edge( + parent_class_nid, + nid, + "defines", + line, + context="property_requirement", + ) + for ref_name in dict.fromkeys(declared_types): + add_edge( + nid, + ensure_named_node(ref_name, line), + "references", + line, + context="field", + ) + return + if (config.ts_module == "tree_sitter_swift" and t == "property_declaration" and parent_class_nid): line = node.start_point[0] + 1 prop_type: str | None = None + declared_type = _swift_property_declared_type(node, source) type_anno = _swift_property_type_node(node) if type_anno is not None: refs: list[tuple[str, str]] = [] @@ -3015,6 +3396,23 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: prop_name = _swift_property_name(node, source) if prop_name and prop_type: type_table[prop_name] = prop_type + swift_property_types_by_owner.setdefault(parent_class_nid, {})[ + prop_name + ] = prop_type + if prop_name: + prop_nid = _make_id(parent_class_nid, "property", prop_name) + add_node( + prop_nid, + prop_name, + line, + node_type="property", + metadata={ + "declared_type": declared_type or prop_type or "", + "is_settable": _swift_property_is_settable(node), + "is_static": _swift_declaration_is_static(node, source), + }, + ) + add_edge(parent_class_nid, prop_nid, "defines", line, context="property") return if (config.ts_module == "tree_sitter_scala" @@ -3074,7 +3472,9 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # Function types if t in config.function_types: # Swift deinit/subscript have no name field — resolve before generic fallback - if t == "deinit_declaration": + if t == "init_declaration": + func_name: str | None = "init" + elif t == "deinit_declaration": func_name: str | None = "deinit" elif t == "subscript_declaration": func_name = "subscript" @@ -3097,13 +3497,36 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: return line = node.start_point[0] + 1 + swift_facts: dict = {} + identity_name = func_name + if config.ts_module == "tree_sitter_swift": + swift_facts = _swift_function_facts(node, source, func_name) + # Preserve legacy ids for non-overloaded declarations. Overloads + # include their signature so sibling definitions remain distinct. + owner = parent_class_nid or file_nid + owner_name = (owner, func_name) + seen_count = swift_callable_name_counts.get(owner_name, 0) + swift_callable_name_counts[owner_name] = seen_count + 1 + if _swift_is_overloaded(node, source, func_name) or seen_count: + identity_name = str(swift_facts["identity_signature"]) if parent_class_nid: - func_nid = _make_id(parent_class_nid, func_name) - add_node(func_nid, f".{func_name}()", line) + func_nid = _make_id(parent_class_nid, identity_name) + add_node( + func_nid, + f".{func_name}()", + line, + node_type=( + "protocol_requirement" + if swift_facts.get("is_protocol_requirement") + else None + ), + metadata=swift_facts or None, + ) add_edge(parent_class_nid, func_nid, "method", line) + function_owner_by_nid[func_nid] = parent_class_nid else: - func_nid = _make_id(stem, func_name) - add_node(func_nid, f"{func_name}()", line) + func_nid = _make_id(stem, identity_name) + add_node(func_nid, f"{func_name}()", line, metadata=swift_facts or None) add_edge(file_nid, func_nid, "contains", line) callable_def_nids.add(func_nid) # function / method def is callable if config.ts_module == "tree_sitter_python": @@ -3293,7 +3716,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: for p in node.children: if p.type != "parameter": continue - type_node = p.child_by_field_name("type") + type_node = _swift_parameter_type_node(p) refs: list[tuple[str, str]] = [] _swift_collect_type_refs(type_node, source, False, refs) param_type: str | None = None @@ -3308,11 +3731,28 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # table; later params with the same name win, which is fine # for the depth-1 member-call resolution we do). if param_type: + simple_ids = [ + c for c in p.children if c.type == "simple_identifier" + ] name_node = p.child_by_field_name("name") + if name_node is not None and name_node.type != "simple_identifier": + name_node = None + if name_node is None and simple_ids: + name_node = simple_ids[-1] pname = _read_text(name_node, source) if name_node else None if pname: - type_table[pname] = param_type - return_node = node.child_by_field_name("return_type") + swift_receiver_types_by_caller.setdefault(func_nid, {})[ + pname + ] = param_type + return_node = None + arrow_seen = False + for child in node.children: + if not child.is_named and child.type == "->": + arrow_seen = True + continue + if arrow_seen and child.is_named: + return_node = child + break if return_node is not None: refs = [] _swift_collect_type_refs(return_node, source, False, refs) @@ -3538,6 +3978,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: normalised = raw.strip("()").lstrip(".") label_to_nid[normalised] = n["id"] label_to_nid_ci[normalised.lower()] = n["id"] + swift_callable_candidates: dict[str, list[dict]] = {} + if config.ts_module == "tree_sitter_swift": + for candidate in nodes: + if candidate.get("id") not in callable_def_nids: + continue + normalised = str(candidate.get("label", "")).strip("()").lstrip(".") + if normalised: + swift_callable_candidates.setdefault(normalised, []).append(candidate) seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup @@ -3734,10 +4182,15 @@ def walk_calls( is_member_call: bool = False is_this_field_call: bool = False swift_receiver: str | None = None + swift_receiver_path: list[str] = [] + swift_receiver_call: dict | None = None + swift_call_facts: dict = {} member_receiver: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": + swift_types = swift_receiver_types_by_caller.get(caller_nid, {}) + swift_call_facts = _swift_call_facts(node, source, swift_types) # Swift: first child may be simple_identifier or navigation_expression first = node.children[0] if node.children else None if first: @@ -3745,15 +4198,44 @@ def walk_calls( callee_name = _read_text(first, source) elif first.type == "navigation_expression": is_member_call = True - for child in first.children: - if child.type == "navigation_suffix": - for sc in child.children: - if sc.type == "simple_identifier": - callee_name = _read_text(sc, source) + segments = _swift_navigation_segments(first, source) + if segments: + callee_name = segments[-1] + swift_receiver_path = segments[:-1] # #1356: capture the receiver so the cross-file pass can # resolve it through the file's type table. recv_node = first.children[0] if first.children else None swift_receiver = _swift_receiver_name(recv_node, source) + if swift_receiver_path: + non_self = [s for s in swift_receiver_path if s != "self"] + swift_receiver = non_self[0] if non_self else "self" + if recv_node is not None and recv_node.type == "call_expression": + inner_first = recv_node.children[0] if recv_node.children else None + inner_segments = _swift_navigation_segments(inner_first, source) + inner_callee = "" + inner_receiver_path: list[str] = [] + if ( + inner_first is not None + and inner_first.type == "simple_identifier" + ): + inner_callee = _read_text(inner_first, source) + elif inner_segments: + inner_callee = inner_segments[-1] + inner_receiver_path = inner_segments[:-1] + if inner_callee: + swift_receiver_call = { + "callee": inner_callee, + "receiver_path": inner_receiver_path, + **_swift_call_facts(recv_node, source, swift_types), + } + if inner_receiver_path: + inner_receiver_type = swift_types.get( + inner_receiver_path[0] + ) + if inner_receiver_type: + swift_receiver_call[ + "receiver_type" + ] = inner_receiver_type elif config.ts_module == "tree_sitter_kotlin": # Kotlin: first child may be simple_identifier/identifier or # navigation_expression. PyPI's `tree_sitter_kotlin` produces @@ -3972,7 +4454,10 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - if _java_defer or ( + _swift_defer = ( + config.ts_module == "tree_sitter_swift" and is_member_call + ) + if _swift_defer or _java_defer or ( is_member_call and member_receiver and ( @@ -3982,6 +4467,32 @@ def walk_calls( ) ): tgt_nid = None + elif config.ts_module == "tree_sitter_swift": + swift_candidates = list( + swift_callable_candidates.get(callee_name, []) + ) + caller_owner = function_owner_by_nid.get(caller_nid) + if caller_owner: + owned = [ + candidate + for candidate in swift_candidates + if function_owner_by_nid.get(str(candidate.get("id"))) + == caller_owner + ] + if owned: + swift_candidates = owned + swift_matches = ( + swift_candidates + if len(swift_candidates) == 1 + else [ + candidate + for candidate in swift_candidates + if _swift_overload_match(candidate, swift_call_facts) + ] + ) + tgt_nid = ( + swift_matches[0]["id"] if len(swift_matches) == 1 else None + ) else: tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: @@ -4009,6 +4520,18 @@ def walk_calls( "source_location": f"L{node.start_point[0] + 1}", "receiver": swift_receiver or member_receiver, } + if config.ts_module == "tree_sitter_swift": + rc_entry.update(swift_call_facts) + rc_entry["receiver_path"] = swift_receiver_path + if swift_receiver: + receiver_type = swift_receiver_types_by_caller.get( + caller_nid, {} + ).get(swift_receiver) + if receiver_type: + rc_entry["receiver_type"] = receiver_type + if swift_receiver_call: + rc_entry["receiver_call"] = swift_receiver_call + rc_entry["lang"] = "swift" # Ruby: attach the receiver's inferred type from the method's # local `var = Const.new` bindings, when unambiguously known. if member_receiver and config.ts_module == "tree_sitter_ruby": @@ -4255,8 +4778,17 @@ def walk_calls( # method bodies so `x.method()` on a later line resolves — class-level # properties are typed in the walk, but method-body locals were not (#1604). if config.ts_module == "tree_sitter_swift": - for _caller_nid, body_node in function_bodies: - _swift_local_var_types(body_node, source, type_table) + for caller_nid, body_node in function_bodies: + scoped_types = dict( + swift_property_types_by_owner.get( + function_owner_by_nid.get(caller_nid, ""), + {}, + ) + ) + scoped_types.update(swift_receiver_types_by_caller.get(caller_nid, {})) + _swift_local_var_types(body_node, source, scoped_types) + if scoped_types: + swift_receiver_types_by_caller[caller_nid] = scoped_types # JS/TS: bodies already walked with their own caller_nid (const-assigned # arrows, methods). An INLINE/returned arrow or function-expression that is diff --git a/graphify/extractors/swiftpm.py b/graphify/extractors/swiftpm.py new file mode 100644 index 000000000..a03b013b5 --- /dev/null +++ b/graphify/extractors/swiftpm.py @@ -0,0 +1,426 @@ +"""Deterministic Swift Package Manager manifest extraction.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _file_stem, _make_id, _read_text + + +def _child(node, type_name: str): + return next((c for c in node.children if c.type == type_name), None) + + +def _call_name(node, source: bytes) -> str: + if node.type != "call_expression" or not node.children: + return "" + callee = node.children[0] + if callee.type == "simple_identifier": + return _read_text(callee, source).strip() + if callee.type == "prefix_expression": + ids = [c for c in callee.children if c.type == "simple_identifier"] + return _read_text(ids[-1], source).strip() if ids else "" + return "" + + +def _string_value(node, source: bytes) -> str | None: + if node is None or node.type != "line_string_literal": + return None + text = _read_text(node, source).strip() + return text[1:-1] if len(text) >= 2 and text[0] == text[-1] == '"' else text + + +def _string_array(node, source: bytes) -> list[str]: + if node is None or node.type != "array_literal": + return [] + out: list[str] = [] + for child in node.children: + value = _string_value(child, source) + if value is not None: + out.append(value) + return out + + +def _dependency_array(node, source: bytes) -> list[str]: + """Target dependency strings plus `.product(name:)`/`.target(name:)` entries.""" + out = _string_array(node, source) + if node is None or node.type != "array_literal": + return out + for child in node.children: + if child.type != "call_expression": + continue + args = _arguments(child, source) + name = _string_value(args.get("name"), source) + if name: + out.append(name) + return out + + +def _arguments(call, source: bytes) -> dict[str, Any]: + suffix = _child(call, "call_suffix") + values = _child(suffix, "value_arguments") if suffix is not None else None + out: dict[str, Any] = {} + if values is None: + return out + for argument in values.children: + if argument.type != "value_argument": + continue + name_node = argument.child_by_field_name("name") + value_node = argument.child_by_field_name("value") + if name_node is None or value_node is None: + continue + out[_read_text(name_node, source).strip()] = value_node + return out + + +def _walk_calls(node): + if node.type == "call_expression": + yield node + for child in node.children: + yield from _walk_calls(child) + + +_SWIFTPM_SOURCE_SUFFIXES = frozenset({ + ".swift", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", + ".m", ".mm", ".s", ".S", +}) + + +def link_swift_package_sources(per_file: list[dict], paths: list[Path]) -> None: + """Link manifest targets to the already accepted extraction corpus. + + Source discovery must not walk the filesystem from a cached Package.swift + result: doing so bypasses ignore rules and makes membership stale when a + source is added or deleted without changing the manifest. This post-pass + consumes only ``paths`` selected by the caller and re-evaluates target + ``path``/``sources``/``exclude`` facts on every extraction. + """ + memberships: dict[Path, list[dict[str, Any]]] = {} + + def _under(candidate: Path, root: Path) -> bool: + try: + candidate.relative_to(root) + return True + except ValueError: + return False + + accepted: list[tuple[Path, Path, str]] = [] + for candidate, result in zip(paths, per_file): + if ( + candidate.name == "Package.swift" + or candidate.suffix not in _SWIFTPM_SOURCE_SUFFIXES + ): + continue + expected_id = _make_id(str(candidate)) + file_node = next( + ( + node + for node in result.get("nodes", []) + if str(node.get("id", "")) == expected_id + and str(node.get("source_file", "")) == str(candidate) + ), + None, + ) + if file_node is not None: + accepted.append((candidate, candidate.resolve(), str(file_node["id"]))) + + for result, manifest_path in zip(per_file, paths): + specs = list(result.pop("swiftpm_targets", []) or []) + target_ids = dict(result.pop("swiftpm_target_ids", {}) or {}) + if manifest_path.name != "Package.swift" or not specs: + continue + manifest_root = manifest_path.parent.resolve() + seen_edges = { + (str(edge.get("source")), str(edge.get("target")), str(edge.get("relation"))) + for edge in result.get("edges", []) + } + for spec in specs: + name = str(spec.get("name", "")) + target_nid = str(spec.get("nid", "")) + if not name or not target_nid: + continue + explicit = spec.get("path") + if explicit: + source_root = (manifest_root / str(explicit)).resolve() + elif spec.get("kind") == "testTarget": + source_root = (manifest_root / "Tests" / name).resolve() + else: + source_root = (manifest_root / "Sources" / name).resolve() + source_filters = [ + (source_root / str(value)).resolve() + for value in spec.get("sources", []) or [] + ] + excluded = [ + (source_root / str(value)).resolve() + for value in spec.get("exclude", []) or [] + ] + for _source_path, resolved_source, source_nid in accepted: + if not _under(resolved_source, source_root): + continue + if source_filters and not any( + resolved_source == selected or _under(resolved_source, selected) + for selected in source_filters + ): + continue + if any( + resolved_source == ignored or _under(resolved_source, ignored) + for ignored in excluded + ): + continue + key = (target_nid, source_nid, "contains") + if key not in seen_edges: + seen_edges.add(key) + result.setdefault("edges", []).append({ + "source": target_nid, + "target": source_nid, + "relation": "contains", + "context": "target_source", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str(manifest_path), + "source_location": f"L{int(spec.get('line', 1))}", + "weight": 1.0, + "language": "swift", + "language_family": "native", + }) + memberships.setdefault(resolved_source, []).append({ + "target_ids": target_ids, + }) + + # Internal imports use package-qualified target ids. Rewire only when the + # importing file belongs unambiguously to one manifest; external imports keep + # the existing shared module anchor. + for result, source_path in zip(per_file, paths): + owners = memberships.get(source_path.resolve(), []) + if len(owners) != 1: + continue + target_ids = owners[0]["target_ids"] + module_labels = { + str(node.get("id")): str(node.get("label", "")) + for node in result.get("nodes", []) + if node.get("type") == "module" + } + rewired_old_ids: set[str] = set() + for edge in result.get("edges", []): + if edge.get("relation") != "imports": + continue + old_target = str(edge.get("target", "")) + new_target = target_ids.get(module_labels.get(old_target, "")) + if new_target and new_target != old_target: + edge["target"] = new_target + rewired_old_ids.add(old_target) + if rewired_old_ids: + referenced = { + str(endpoint) + for edge in result.get("edges", []) + for endpoint in (edge.get("source"), edge.get("target")) + } + result["nodes"] = [ + node + for node in result.get("nodes", []) + if node.get("id") not in rewired_old_ids + or str(node.get("id")) in referenced + ] + + +def extract_swift_package_manifest(path: Path) -> dict: + """Extract package, product, target, dependency and source membership edges.""" + try: + from tree_sitter import Language, Parser + import tree_sitter_swift + + source = path.read_bytes() + root = Parser(Language(tree_sitter_swift.language())).parse(source).root_node + except Exception as exc: + return {"nodes": [], "edges": [], "error": str(exc)} + + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_nodes: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() + + def add_node(nid: str, label: str, line: int, node_type: str, **extra: Any) -> None: + if nid in seen_nodes: + return + seen_nodes.add(nid) + node = { + "id": nid, + "label": label, + "file_type": "code", + "type": node_type, + "source_file": str_path, + "source_location": f"L{line}", + "language": "swift", + "language_family": "native", + } + node.update({k: v for k, v in extra.items() if v not in (None, "", [], {})}) + nodes.append(node) + + def add_edge(src: str, tgt: str, relation: str, line: int, context: str | None = None) -> None: + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) + edge = { + "source": src, + "target": tgt, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + "language": "swift", + "language_family": "native", + } + if context: + edge["context"] = context + edges.append(edge) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1, "file") + + calls = list(_walk_calls(root)) + package_call = next((call for call in calls if _call_name(call, source) == "Package"), None) + if package_call is None: + return { + "nodes": nodes, + "edges": edges, + "language": "swift", + "language_family": "native", + } + package_args = _arguments(package_call, source) + package_name = _string_value(package_args.get("name"), source) or path.parent.name + package_nid = _make_id("pkg", package_name) + add_node(package_nid, package_name, package_call.start_point[0] + 1, "package", ecosystem="swiftpm") + add_edge(file_nid, package_nid, "contains", package_call.start_point[0] + 1) + + target_specs: dict[str, dict[str, Any]] = {} + product_specs: list[tuple[str, str, list[str], int]] = [] + dependency_names: list[tuple[str, int]] = [] + for call in calls: + kind = _call_name(call, source) + args = _arguments(call, source) + line = call.start_point[0] + 1 + if kind in ( + "target", + "executableTarget", + "testTarget", + "systemLibrary", + "binaryTarget", + "plugin", + "macro", + ): + name = _string_value(args.get("name"), source) + if not name: + continue + target_specs[name] = { + "kind": kind, + "dependencies": _dependency_array(args.get("dependencies"), source), + "path": _string_value(args.get("path"), source), + "sources": _string_array(args.get("sources"), source), + "exclude": _string_array(args.get("exclude"), source), + "line": line, + } + elif kind in ("library", "executable"): + name = _string_value(args.get("name"), source) + if name: + product_specs.append((kind, name, _string_array(args.get("targets"), source), line)) + elif kind == "package": + raw = ( + _string_value(args.get("name"), source) + or _string_value(args.get("id"), source) + or _string_value(args.get("url"), source) + or _string_value(args.get("path"), source) + ) + if raw: + tail = raw.rstrip("/").rsplit("/", 1)[-1] + dependency_names.append((tail.removesuffix(".git"), line)) + + manifest_scope = _file_stem(path) + target_nids = { + name: _make_id(manifest_scope, "swift_target", name) + for name in target_specs + } + for name, spec in target_specs.items(): + target_nid = target_nids[name] + add_node( + target_nid, + name, + int(spec["line"]), + "module", + swiftpm_kind=spec["kind"], + package=package_name, + ) + add_edge(package_nid, target_nid, "contains", int(spec["line"]), context="target") + for dep in spec["dependencies"]: + if dep not in target_specs: + dependency_nid = _make_id("swift_external_module", dep) + add_node( + dependency_nid, + dep, + int(spec["line"]), + "module", + swiftpm_kind="dependency", + external=True, + ) + else: + dependency_nid = target_nids[dep] + add_edge( + target_nid, + dependency_nid, + "depends_on", + int(spec["line"]), + context="target_dependency", + ) + + for kind, name, targets, line in product_specs: + product_nid = _make_id(manifest_scope, "swift_product", name) + add_node(product_nid, name, line, "product", swiftpm_kind=kind, package=package_name) + add_edge(package_nid, product_nid, "contains", line, context="product") + for target in targets: + target_nid = target_nids.get(target) + if target_nid is None: + target_nid = _make_id("swift_external_module", target) + add_node( + target_nid, + target, + line, + "module", + swiftpm_kind="dependency", + external=True, + ) + add_edge(product_nid, target_nid, "contains", line, context="product_target") + + for dependency, line in dependency_names: + dependency_nid = _make_id("pkg", dependency) + add_node( + dependency_nid, + dependency, + line, + "package", + ecosystem="swiftpm", + external=True, + ) + add_edge(package_nid, dependency_nid, "depends_on", line, context="dependency") + + return { + "nodes": nodes, + "edges": edges, + "swiftpm_targets": [ + { + "name": name, + "nid": target_nids[name], + "kind": spec["kind"], + "path": spec.get("path"), + "sources": list(spec.get("sources") or []), + "exclude": list(spec.get("exclude") or []), + "line": int(spec["line"]), + } + for name, spec in target_specs.items() + ], + "swiftpm_target_ids": target_nids, + "language": "swift", + "language_family": "native", + } diff --git a/tests/test_swift_fidelity.py b/tests/test_swift_fidelity.py new file mode 100644 index 000000000..0937d5e92 --- /dev/null +++ b/tests/test_swift_fidelity.py @@ -0,0 +1,612 @@ +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 _labels(result: dict) -> dict[str, str]: + return {str(node["id"]): str(node.get("label", "")) for node in result["nodes"]} + + +def _edges(result: dict, relation: str) -> list[tuple[str, str, dict]]: + labels = _labels(result) + return [ + (labels.get(str(edge["source"]), ""), labels.get(str(edge["target"]), ""), edge) + for edge in result["edges"] + if edge.get("relation") == relation + ] + + +def _nodes(result: dict) -> dict[str, dict]: + return {str(node["id"]): node for node in result["nodes"]} + + +def _method_owner(result: dict, method_id: str) -> str: + labels = _labels(result) + return next( + labels.get(str(edge["source"]), "") + for edge in result["edges"] + if edge.get("relation") == "method" and str(edge.get("target")) == method_id + ) + + +def test_swift_overloads_have_distinct_signature_ids_and_literal_calls_resolve(tmp_path: Path) -> None: + source = _write( + tmp_path / "Service.swift", + """class Service { + func load(_ id: String) {} + func load(_ id: Int) {} + func go() { load("x"); load(1) } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + overloads = [node for node in result["nodes"] if node.get("label") == ".load()"] + assert len(overloads) == 2 + assert len({node["id"] for node in overloads}) == 2 + go_calls = [target for source, target, _ in _edges(result, "calls") if source == ".go()"] + assert go_calls.count(".load()") == 2 + + +def test_swift_unique_default_argument_call_remains_extracted(tmp_path: Path) -> None: + source = _write( + tmp_path / "Defaults.swift", + 'func greet(_ name: String = "world") {}\nfunc go() { greet() }\n', + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + calls = [ + edge + for caller, target, edge in _edges(result, "calls") + if caller == "go()" and target == "greet()" + ] + assert len(calls) == 1 + assert calls[0]["confidence"] == "EXTRACTED" + + +def test_swift_static_call_resolves_without_a_type_table(tmp_path: Path) -> None: + service = _write( + tmp_path / "Service.swift", + "class Service { static func build() {} }\n", + ) + main = _write(tmp_path / "Main.swift", "func go() { Service.build() }\n") + result = extract( + [service, main], + cache_root=tmp_path / ".cache", + parallel=False, + ) + calls = [ + edge + for caller, target, edge in _edges(result, "calls") + if caller == "go()" and target == ".build()" + ] + assert len(calls) == 1 + assert calls[0]["confidence"] == "EXTRACTED" + + +def test_swift_overload_identity_covers_extensions_and_declaration_details( + tmp_path: Path, +) -> None: + source = _write( + tmp_path / "Overloads.swift", + """protocol Left {} +protocol Right {} +class Service { + func load(_ value: String) {} + static func dispatch(_ value: Int) {} + func dispatch(_ value: Int) {} +} +extension Service { func load(_ value: Int) {} } +func make() -> Int { 1 } +func make() -> String { "" } +func constrained(_ value: T) {} +func constrained(_ value: T) {} +func run(_ service: Service) { service.load(1) } +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + assert len([node for node in result["nodes"] if node.get("label") == ".load()"]) == 2 + assert len([node for node in result["nodes"] if node.get("label") == ".dispatch()"]) == 2 + assert len([node for node in result["nodes"] if node.get("label") == "make()"]) == 2 + assert len([node for node in result["nodes"] if node.get("label") == "constrained()"]) == 2 + + nodes = _nodes(result) + call_targets = [ + nodes[str(edge["target"])] + for edge in result["edges"] + if edge.get("relation") == "calls" + and _labels(result).get(str(edge["source"])) == "run()" + ] + assert any( + node.get("label") == ".load()" + and (node.get("metadata") or {}).get("parameter_types") == ["Int"] + for node in call_targets + ) + + +def test_swift_receiver_types_are_scoped_per_method(tmp_path: Path) -> None: + source = _write( + tmp_path / "Scopes.swift", + """class A { func ping() {} } +class B { func ping() {} } +class Runner { + func one(_ value: A) { value.ping() } + func two(_ value: B) { value.ping() } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + labels = _labels(result) + calls = { + labels[str(edge["source"])]: _method_owner(result, str(edge["target"])) + for edge in result["edges"] + if edge.get("relation") == "calls" + and labels.get(str(edge.get("target"))) == ".ping()" + } + assert calls == {".one()": "A", ".two()": "B"} + + +def test_swift_typed_arguments_and_trailing_closures_select_overloads( + tmp_path: Path, +) -> None: + source = _write( + tmp_path / "Calls.swift", + """class Service { + func load(_ value: String) {} + func load(_ value: Int) {} +} +func perform() {} +func perform(_ body: () -> Void) {} +func run(_ service: Service, _ value: String) { + service.load(value) + perform {} +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + nodes = _nodes(result) + labels = _labels(result) + targets = [ + nodes[str(edge["target"])] + for edge in result["edges"] + if edge.get("relation") == "calls" + and labels.get(str(edge["source"])) == "run()" + ] + assert any( + node.get("label") == ".load()" + and (node.get("metadata") or {}).get("parameter_types") == ["String"] + for node in targets + ) + assert any( + node.get("label") == "perform()" + and (node.get("metadata") or {}).get("arity") == 1 + for node in targets + ) + + +def test_swift_protocol_requirements_associated_types_and_aliases(tmp_path: Path) -> None: + source = _write( + tmp_path / "Repository.swift", + """protocol Repository { + associatedtype Item + var item: Item { get } + func load(_ id: String) -> Item +} +class Store: Repository { + typealias Item = String + var item: String = "" + func load(_ id: String) -> String { item } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + types = {node.get("type") for node in result["nodes"]} + assert "associated_type" in types + assert "type_alias" in types + assert "protocol_requirement" in types + assert "protocol_property_requirement" in types + requirement_edges = [ + edge for _, _, edge in _edges(result, "implements") + if edge.get("context", "").startswith("protocol_") + ] + assert {edge["context"] for edge in requirement_edges} == { + "protocol_requirement", + "protocol_property_requirement", + } + + +def test_swift_cross_file_protocol_requirements_use_associated_type_bindings( + tmp_path: Path, +) -> None: + protocol = _write( + tmp_path / "Repository.swift", + """protocol Repository { + associatedtype Item + init(value: Item) + subscript(index: Int) -> Item { get } + var item: Item { get set } + func save(_ item: Item) -> Item +} +""", + ) + store = _write( + tmp_path / "Store.swift", + """class Store: Repository { + typealias Item = String + required init(value: String) {} + subscript(index: Int) -> String { "" } + var item: String = "" + func save(_ item: String) -> String { item } +} +""", + ) + result = extract( + [protocol, store], + cache_root=tmp_path, + parallel=False, + ) + labels = _labels(result) + type_conformances = [ + (source, target) + for source, target, edge in _edges(result, "implements") + if not edge.get("context") + ] + assert ("Store", "Repository") in type_conformances + + member_implementations = [ + edge + for edge in result["edges"] + if edge.get("relation") == "implements" + and str(edge.get("context", "")).startswith("protocol_") + ] + assert len(member_implementations) == 4 + assert {labels[str(edge["source"])] for edge in member_implementations} == { + ".init()", + ".subscript()", + ".save()", + "item", + } + + +def test_swift_protocol_requirement_mismatches_fail_closed(tmp_path: Path) -> None: + source = _write( + tmp_path / "Mismatch.swift", + """protocol Contract { + var value: String { get set } + func load(_ value: String) -> String +} +class Broken: Contract { + let value: Int = 0 + static func load(_ value: String) -> Int { 0 } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + assert not [ + edge + for edge in result["edges"] + if edge.get("relation") == "implements" + and str(edge.get("context", "")).startswith("protocol_") + ] + + +def test_swift_protocol_generic_alias_async_and_static_property_semantics( + tmp_path: Path, +) -> None: + source = _write( + tmp_path / "AdvancedProtocol.swift", + """protocol Contract { + associatedtype Item + static var count: Int { get } + func save(_ values: Item) async -> Item +} +struct Good: Contract { + typealias Item = [String] + static var count: Int = 0 + func save(_ values: [String]) -> [String] { values } +} +struct Bad: Contract { + typealias Item = [String] + var count: Int = 0 + func save(_ values: [String]) async -> [String] { values } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + labels = _labels(result) + owners = { + str(edge["target"]): labels.get(str(edge["source"]), "") + for edge in result["edges"] + if edge.get("relation") in ("method", "defines") + } + member_edges = [ + edge + for edge in result["edges"] + if edge.get("relation") == "implements" + and str(edge.get("context", "")).startswith("protocol_") + ] + assert { + (owners.get(str(edge["source"])), labels.get(str(edge["source"]))) + for edge in member_edges + } == {("Good", ".save()"), ("Good", "count"), ("Bad", ".save()")} + + +def test_swift_protocol_extension_methods_are_default_implementations( + tmp_path: Path, +) -> None: + source = _write( + tmp_path / "Defaults.swift", + """protocol ValueProvider { func value() -> Int } +extension ValueProvider { func value() -> Int { 1 } } +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + value_nodes = [ + node for node in result["nodes"] if node.get("label") == ".value()" + ] + assert len(value_nodes) == 2 + assert sum(node.get("type") == "protocol_requirement" for node in value_nodes) == 1 + + +def test_swift_deep_property_receiver_and_factory_return_chain_resolve(tmp_path: Path) -> None: + source = _write( + tmp_path / "Chain.swift", + """class Service { func fetch() {} } +class Container { let service: Service = Service() } +class Root { let container: Container = Container() } +class Factory { static func make() -> Service { Service() } } +class Runner { + let root: Root = Root() + func run() { self.root.container.service.fetch() } + func runFactory() { Factory.make().fetch() } +} +""", + ) + result = extract([source], cache_root=tmp_path / ".cache", parallel=False) + run_calls = [target for source, target, _ in _edges(result, "calls") if source == ".run()"] + factory_calls = [target for source, target, _ in _edges(result, "calls") if source == ".runFactory()"] + assert ".fetch()" in run_calls + assert ".fetch()" in factory_calls + assert ".make()" in factory_calls + + +def test_swift_free_factory_and_cross_file_overloads_resolve(tmp_path: Path) -> None: + definitions = _write( + tmp_path / "Definitions.swift", + """class Service { func fetch() {} } +func make() -> Service { Service() } +func load(_ value: String) {} +func load(_ value: Int) {} +""", + ) + usage = _write( + tmp_path / "Usage.swift", + "func run() { make().fetch(); load(1) }\n", + ) + result = extract( + [definitions, usage], + cache_root=tmp_path / ".cache", + parallel=False, + ) + nodes = _nodes(result) + labels = _labels(result) + targets = [ + nodes[str(edge["target"])] + for edge in result["edges"] + if edge.get("relation") == "calls" + and labels.get(str(edge["source"])) == "run()" + ] + assert {node.get("label") for node in targets} >= { + "make()", + ".fetch()", + "load()", + } + assert any( + node.get("label") == "load()" + and (node.get("metadata") or {}).get("parameter_types") == ["Int"] + for node in targets + ) + + +def test_swift_extension_merge_survives_canonical_id_remap_and_stubs( + tmp_path: Path, +) -> None: + declaration = _write(tmp_path / "Foo.swift", "class Foo {}\n") + extension = _write( + tmp_path / "Foo+Extension.swift", + "extension Foo { func describe() {} }\n", + ) + usage = _write( + tmp_path / "Use.swift", + "func use(_ value: Foo) { value.describe() }\n", + ) + result = extract( + [declaration, extension, usage], + cache_root=tmp_path, + parallel=False, + ) + foo_nodes = [node for node in result["nodes"] if node.get("label") == "Foo"] + assert len(foo_nodes) == 1 + foo_id = str(foo_nodes[0]["id"]) + describe_ids = { + str(edge["target"]) + for edge in result["edges"] + if edge.get("relation") == "method" and str(edge.get("source")) == foo_id + } + assert any(_labels(result).get(method_id) == ".describe()" for method_id in describe_ids) + + +def test_swiftpm_manifest_maps_targets_products_dependencies_and_sources(tmp_path: Path) -> None: + manifest = _write( + tmp_path / "Package.swift", + """// swift-tools-version: 5.9 +import PackageDescription +let package = Package( + name: "Demo", + products: [.library(name: "Demo", targets: ["Core"])], + dependencies: [.package(url: "https://example.com/Dep.git", from: "1.0.0")], + targets: [ + .target(name: "Core"), + .target(name: "App", dependencies: ["Core"]) + ] +) +""", + ) + core = _write(tmp_path / "Sources" / "Core" / "Core.swift", "public func core() {}\n") + app = _write(tmp_path / "Sources" / "App" / "Main.swift", "import Core\nfunc main() { core() }\n") + result = extract( + [manifest, core, app], + cache_root=tmp_path / ".cache", + parallel=False, + ) + labels = set(_labels(result).values()) + node_ids = [str(node["id"]) for node in result["nodes"]] + assert len(node_ids) == len(set(node_ids)) + assert all( + str(edge[endpoint]) in set(node_ids) + for edge in result["edges"] + for endpoint in ("source", "target") + ) + assert {"Demo", "Core", "App", "Core.swift", "Main.swift"} <= labels + assert ("Demo", "Core") in [(a, b) for a, b, _ in _edges(result, "contains")] + assert ("Core", "Core.swift") in [(a, b) for a, b, _ in _edges(result, "contains")] + assert ("Main.swift", "Core") in [(a, b) for a, b, _ in _edges(result, "imports")] + assert ("Demo", "Dep") in [(a, b) for a, b, _ in _edges(result, "depends_on")] + + graph = build_from_json(result) + labels = {str(node_id): str(data.get("label", "")) for node_id, data in graph.nodes(data=True)} + built_dependencies = { + (labels.get(str(source), ""), labels.get(str(target), "")) + for source, target, data in graph.edges(data=True) + if data.get("relation") == "depends_on" + } + assert ("Demo", "Dep") in built_dependencies + + +def test_swiftpm_membership_honors_input_scope_sources_excludes_and_cache( + tmp_path: Path, +) -> None: + manifest = _write( + tmp_path / "Package.swift", + """import PackageDescription +let package = Package( + name: "Scoped", + targets: [.target( + name: "Core", + exclude: ["Selected/Excluded.swift"], + sources: ["Selected"] + )] +) +""", + ) + included = _write( + tmp_path / "Sources" / "Core" / "Selected" / "Included.swift", + "public func included() {}\n", + ) + excluded = _write( + tmp_path / "Sources" / "Core" / "Selected" / "Excluded.swift", + "public func excluded() {}\n", + ) + outside = _write( + tmp_path / "Sources" / "Core" / "Outside.swift", + "public func outside() {}\n", + ) + + cache = tmp_path / ".cache" + first = extract( + [manifest, included, excluded, outside], + cache_root=cache, + parallel=False, + ) + first_members = { + target + for source, target, edge in _edges(first, "contains") + if source == "Core" and edge.get("context") == "target_source" + } + assert first_members == {"Included.swift"} + + added = _write( + tmp_path / "Sources" / "Core" / "Selected" / "Added.swift", + "public func added() {}\n", + ) + included.unlink() + second = extract( + [manifest, added, excluded, outside], + cache_root=cache, + parallel=False, + ) + second_members = { + target + for source, target, edge in _edges(second, "contains") + if source == "Core" and edge.get("context") == "target_source" + } + assert second_members == {"Added.swift"} + + manifest_only = extract([manifest], cache_root=cache, parallel=False) + assert not [ + edge + for _, _, edge in _edges(manifest_only, "contains") + if edge.get("context") == "target_source" + ] + + +def test_swiftpm_same_named_targets_are_manifest_qualified(tmp_path: Path) -> None: + one_manifest = _write( + tmp_path / "One" / "Package.swift", + 'import PackageDescription\nlet package = Package(name: "Same", targets: [.target(name: "Core")])\n', + ) + one_source = _write( + tmp_path / "One" / "Sources" / "Core" / "One.swift", + "public func one() {}\n", + ) + two_manifest = _write( + tmp_path / "Two" / "Package.swift", + 'import PackageDescription\nlet package = Package(name: "Same", targets: [.target(name: "Core")])\n', + ) + two_source = _write( + tmp_path / "Two" / "Sources" / "Core" / "Two.swift", + "public func two() {}\n", + ) + result = extract( + [one_manifest, one_source, two_manifest, two_source], + cache_root=tmp_path / ".cache", + parallel=False, + ) + core_nodes = [ + node + for node in result["nodes"] + if node.get("label") == "Core" and node.get("type") == "module" + ] + assert len(core_nodes) == 2 + assert len({str(node["id"]) for node in core_nodes}) == 2 + + +def test_swiftpm_does_not_link_sources_without_extracted_file_nodes( + tmp_path: Path, +) -> None: + manifest = _write( + tmp_path / "Package.swift", + 'import PackageDescription\nlet package = Package(name: "Native", targets: [.target(name: "Core")])\n', + ) + assembly = _write(tmp_path / "Sources" / "Core" / "start.S", ".globl _start\n") + result = extract( + [manifest, assembly], + cache_root=tmp_path / ".cache", + parallel=False, + ) + node_ids = {str(node["id"]) for node in result["nodes"]} + assert all( + str(edge[endpoint]) in node_ids + for edge in result["edges"] + for endpoint in ("source", "target") + ) + assert not [ + edge + for edge in result["edges"] + if edge.get("context") == "target_source" + ] diff --git a/uv.lock b/uv.lock index 088ebbbdc..eb91dff55 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.15" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },