From f6e8f07e7e211ad249261ef024a89d0d478f22db Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 10 Sep 2026 06:06:20 +0530 Subject: [PATCH 1/6] test: add failing regression for grouped PHP symbol imports --- tests/test_php_type_resolution.py | 236 +++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/tests/test_php_type_resolution.py b/tests/test_php_type_resolution.py index f90b72fcd..8bb23bb41 100644 --- a/tests/test_php_type_resolution.py +++ b/tests/test_php_type_resolution.py @@ -2,7 +2,10 @@ from pathlib import Path -from graphify.extract import extract +import pytest + +from graphify.extract import extract, extract_php +from graphify.extractors.resolution import _resolve_php_type_references def _write(path: Path, text: str) -> Path: @@ -188,3 +191,234 @@ def test_php_import_resolves_when_target_name_prefixes_sibling_classes(tmp_path: assert len(imports) == 1 assert imports[0]["target"] == pivot_id + + +@pytest.mark.parametrize( + ("kind", "symbol", "alias"), + [ + ("function", "slug", "s"), + ("const", "LIMIT", "L"), + ], + ids=["function", "const"], +) +def test_php_grouped_symbol_aliases_are_not_class_imports( + tmp_path: Path, + kind: str, + symbol: str, + alias: str, +): + user = _write( + tmp_path / "User.php", + " Date: Thu, 10 Sep 2026 06:10:23 +0530 Subject: [PATCH 2/6] fix: propagate PHP symbol import kind across use clauses Problem PHP grouped function and const imports acquire class-style graph identities. Their imports edges can be redirected to sourceless FQNs or unrelated classes, and same-named class and symbol imports can collapse into one wrong target. Root cause The PHP import boundary discarded effective import kind before emitting edges. Tree-sitter stores homogeneous group kinds on namespace_use_declaration and homogeneous comma-list kinds only on the first clause, while mixed groups keep kind on each symbol clause. The PHP namespace resolver also classified clauses without this inherited kind. Once class and symbol edges shared a bare ID, the PHP FQN path or generic unique-stub rewire treated both as class references. Approach Derive effective kind from the clause, its declaration, or the first governed clause when the internal PHP extractor creates each edge. Mark function/const edges before deduplication, propagate the same kind when building the class-use map, and skip only marked targets during generic stub rewiring. Public extract_php strips the marker immediately; internal aggregate dispatch retains it through cache and resolution, then removes it before returning the graph. This preserves same-line class/symbol imports, mixed groups, multi-namespace files, and legitimate class relations while retaining symbol targets. Rejected alternatives reconstructed kind from line/target pairs, disabled class FQN resolution, or deleted bad stubs after other relations were corrupted. Verification INITIAL RED: FF [100%] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[const] 2 failed in 0.14s SHARED-STUB RED: FFFF [100%] 4 failed, 1 warning in 0.15s PRODUCER-IDENTITY RED: FFF [100%] FAILED tests/test_php_type_resolution.py::test_php_symbol_import_does_not_hide_same_named_class_import FAILED tests/test_php_type_resolution.py::test_php_mixed_group_preserves_same_named_class_and_function_imports FAILED tests/test_php_type_resolution.py::test_php_symbol_import_survives_multi_namespace_resolution_skip 3 failed, 1 warning in 0.35s PUBLIC-EXTRACTOR RED: F [100%] FAILED tests/test_php_type_resolution.py::test_php_single_file_extractor_hides_symbol_import_marker 1 failed, 1 warning in 0.14s GREEN: ................... [100%] 19 passed, 1 warning in 0.13s PHP LANGUAGE CONTROLS: ................... [100%] 19 passed, 388 deselected, 1 warning in 0.11s FINAL TEST-ONLY MUTATION: FFFFFFF [100%] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_aliases_are_not_class_imports[const] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_import_does_not_share_class_rewire[function] FAILED tests/test_php_type_resolution.py::test_php_grouped_symbol_import_does_not_share_class_rewire[const] FAILED tests/test_php_type_resolution.py::test_php_symbol_import_does_not_hide_same_named_class_import FAILED tests/test_php_type_resolution.py::test_php_mixed_group_preserves_same_named_class_and_function_imports FAILED tests/test_php_type_resolution.py::test_php_symbol_import_survives_multi_namespace_resolution_skip 7 failed in 0.59s mutation_test_exit=1 The repository gate is run after this commit so its record can certify the final commit SHA; the run artifacts carry the gate result and record path. Impact Grouped, homogeneous comma-separated, aliased, same-line, mixed, and multi-namespace function/const imports retain symbol targets and no longer influence class-style resolution. No dependency, public API, or output schema changes. Risk / rollback Internal AST/cache edges temporarily carry a private marker; public direct and aggregate extraction paths are covered for cleanup. Roll back with git revert HEAD while this commit is the branch tip. Closes none --- graphify/extract.py | 52 +++++++++++++++++++++++++++---- graphify/extractors/resolution.py | 37 +++++++++++++++++++--- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484d..91ea838a6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -727,6 +727,33 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st break +def _php_import_kind(node) -> str | None: + kinds = ("function", "const") + local_kind = next((child.type for child in node.children if child.type in kinds), None) + if local_kind: + return local_kind + + parent = node.parent + declaration = parent.parent if parent is not None and parent.type == "namespace_use_group" else parent + if declaration is None or declaration.type != "namespace_use_declaration": + return None + + declaration_kind = next( + (child.type for child in declaration.children if child.type in kinds), + None, + ) + if declaration_kind: + return declaration_kind + + first_clause = next( + (child for child in declaration.children if child.type == "namespace_use_clause"), + None, + ) + if first_clause is None: + return None + return next((child.type for child in first_clause.children if child.type in kinds), None) + + def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: for child in node.children: if child.type in ("qualified_name", "name", "identifier"): @@ -734,7 +761,7 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_ module_name = raw.split("\\")[-1].strip() if module_name: tgt_nid = _make_id(module_name) - edges.append({ + edge = { "source": file_nid, "target": tgt_nid, "relation": "imports", @@ -743,7 +770,10 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_ "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, - }) + } + if _php_import_kind(node) in ("function", "const"): + edge["_php_symbol_import"] = True + edges.append(edge) break @@ -2415,9 +2445,16 @@ def extract_scala(path: Path) -> dict: return _extract_generic(path, _SCALA_CONFIG) +def _extract_php_with_symbol_markers(path: Path) -> dict: + return _extract_generic(path, _PHP_CONFIG) + + def extract_php(path: Path) -> dict: """Extract classes, functions, methods, namespace uses, and calls from a .php file.""" - return _extract_generic(path, _PHP_CONFIG) + result = _extract_php_with_symbol_markers(path) + for edge in result.get("edges", []): + edge.pop("_php_symbol_import", None) + return result # One level of balanced parens (e.g. `Foo #(Bar #(int))`) — bounded so malformed @@ -2721,6 +2758,8 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: remap[stub_id] = target_id if not remap: + for edge in edges: + edge.pop("_php_symbol_import", None) return by_id = {node.get("id"): node for node in nodes if node.get("id")} @@ -2770,7 +2809,8 @@ def _names_own_builtin_base(edge: dict, stub_id: str, remapped_id: str) -> bool: ): edge["source"] = remapped_source target = edge.get("target") - if target in remap: + is_php_symbol_import = bool(edge.pop("_php_symbol_import", False)) + if target in remap and not is_php_symbol_import: remapped_target = remap[str(target)] if not ( is_csharp_scoped_edge @@ -5715,7 +5755,7 @@ def add_existing_edge(edge: dict) -> None: ".kt": extract_kotlin, ".kts": extract_kotlin, ".scala": extract_scala, - ".php": extract_php, + ".php": _extract_php_with_symbol_markers, ".swift": extract_swift, ".lua": extract_lua, ".luau": extract_lua, @@ -5840,7 +5880,7 @@ def add_existing_edge(edge: dict) -> None: "nodejs": extract_js, "ruby": extract_ruby, "lua": extract_lua, - "php": extract_php, + "php": _extract_php_with_symbol_markers, "julia": extract_julia, } diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 7346882f1..95feca02b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3197,14 +3197,19 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: + def _record_use_clause( + clause, + prefix: str, + declaration_kind: str | None = None, + ) -> None: + clause_kind = declaration_kind target = None alias = None saw_as = False for c in clause.children: if c.type in ("function", "const"): - return # not a class import - if c.type == "as": + clause_kind = c.type + elif c.type == "as": saw_as = True elif c.type in ("qualified_name", "name"): if saw_as: @@ -3215,6 +3220,8 @@ def _record_use_clause(clause, prefix: str) -> None: return fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if clause_kind in ("function", "const"): + return if key: uses.setdefault(key, fqn) @@ -3228,17 +3235,35 @@ def walk(n) -> None: elif t == "namespace_use_declaration": prefix = "" group = None + declaration_kind = next( + (c.type for c in n.children if c.type in ("function", "const")), + None, + ) + if declaration_kind is None: + first_clause = next( + (c for c in n.children if c.type == "namespace_use_clause"), + None, + ) + if first_clause is not None: + declaration_kind = next( + ( + c.type + for c in first_clause.children + if c.type in ("function", "const") + ), + None, + ) for c in n.children: if c.type == "namespace_name": prefix = _read_text(c, source) # group-use prefix elif c.type == "namespace_use_group": group = c elif c.type == "namespace_use_clause": - _record_use_clause(c, "") + _record_use_clause(c, "", declaration_kind) if group is not None: for c in group.children: if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + _record_use_clause(c, prefix, declaration_kind) return elif t == "class_declaration": for child in n.children: @@ -3323,6 +3348,8 @@ def _external_stub(fqn: str) -> str: if ref_file not in ns_by_file: continue tgt = edge.get("target") + if relation == "imports" and edge.get("_php_symbol_import"): + continue label = stub_label.get(tgt) uses = uses_by_file.get(ref_file, {}) if not label and relation == "imports": From a6c04ffe77685b00c931d6f26e4871b90e39f324 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 10 Sep 2026 10:24:08 +0530 Subject: [PATCH 3/6] test: expose PHP aliased-import identity loss in multi-namespace files Problem When a PHP file declares more than one namespace, the resolver deliberately skips file-level namespace resolution. An aliased class import loses its imported identity at that point and keeps a bare provisional endpoint, which generic same-label rewiring is then free to redirect to an unrelated class that happens to share the alias. Approach Three regressions at the resolver seam, each failing for a distinct reason: - keeps_imported_identity: a class import whose local alias collides with an unrelated internal class in a two-namespace file. The edge must land on the imported definition, so both an incomplete endpoint and a wrong-class endpoint fail visibly. - identity_is_per_edge: two imports sharing an imported basename must resolve independently. Any file-wide map keyed by name or by target id collapses them and fails here. - does_not_retarget_symbol_import: a control. A declaration-level function import must not be redirected through class-import identity, so a fix for the above cannot widen into the grouped-symbol path. Verification All three fail on this commit; the nineteen existing PHP resolution tests pass. .venv/bin/python -m pytest tests/test_php_type_resolution.py -q 3 failed, 19 passed Observed: the aliased import at L3 targets bare "foo" rather than the sourced Vendor\Foo definition, while the function import at L4 is already correct. --- tests/test_php_type_resolution.py | 115 +++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/tests/test_php_type_resolution.py b/tests/test_php_type_resolution.py index 8bb23bb41..e4dfdecc7 100644 --- a/tests/test_php_type_resolution.py +++ b/tests/test_php_type_resolution.py @@ -4,7 +4,7 @@ import pytest -from graphify.extract import extract, extract_php +from graphify.extract import _rewire_unique_stub_nodes, extract, extract_php from graphify.extractors.resolution import _resolve_php_type_references @@ -422,3 +422,116 @@ def test_php_mixed_group_keeps_only_class_import_in_use_map(tmp_path: Path): for node in result["nodes"] if node.get("label", "").startswith("App\\Helpers\\") } == {"App\\Helpers\\Thing"} + + +def test_php_aliased_import_in_multi_namespace_file_keeps_imported_identity( + tmp_path: Path, +): + imported = _write( + tmp_path / "src/Foo.php", + " Date: Thu, 10 Sep 2026 10:24:48 +0530 Subject: [PATCH 4/6] fix: bind PHP class-import identity to the edge that produced it Problem An aliased PHP class import in a file with more than one namespace resolves to a bare provisional endpoint instead of the class it imports. Generic same-label rewiring can then redirect that endpoint to an unrelated internal class that shares the alias, so the graph records an import edge pointing at the wrong definition. Root cause Class imports carried a single bare endpoint. The imported fully-qualified name and the local alias existed only in a temporary file-level use map, and the multi-namespace bailout discards that map before edges are processed. Once it is gone there is nothing left that distinguishes the imported class from any other symbol with the same tail. Approach Move identity ownership to the producer. Each class import edge now carries its own imported fully-qualified name in metadata, including any group-use prefix, recorded at extraction time where the syntax is still in hand. Resolution reads only that per-edge value, and does so before the file-level namespace gate, so the multi-namespace bailout no longer destroys it. Two properties follow from putting identity on the edge rather than in a shared map. Repeated aliases with the same imported basename resolve independently, because nothing correlates them. Declaration-level function and const imports are classified at extraction and carry no class provenance, so they are not redirected through this path. An earlier draft used a file-wide map keyed by imported tail and local alias. It was discarded: it could not distinguish repeated aliases across namespace blocks, and it could redirect a same-named function or const import to a class. Full namespace-block resolution for inheritance and references remains out of scope. Ambiguous identities are left unresolved rather than guessed. Verification .venv/bin/python -m pytest tests/test_php_type_resolution.py -q 22 passed Reverting either source file independently returns the same three failures, so both halves are load-bearing: reverted graphify/extractors/resolution.py -> 3 failed, 19 passed reverted graphify/extract.py -> 3 failed, 19 passed ruff and py_compile pass on all three changed files. Impact No dependency change and no public output schema change. Import edges for PHP class imports gain a target_fqn metadata key; existing consumers that ignore unknown metadata keys are unaffected. --- graphify/extract.py | 21 ++++++++++++++++++++- graphify/extractors/resolution.py | 14 +++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 91ea838a6..511b797d1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -754,6 +754,20 @@ def _php_import_kind(node) -> str | None: return next((child.type for child in first_clause.children if child.type in kinds), None) +def _php_import_fqn(node, source: bytes, raw: str) -> str: + parent = node.parent + if parent is not None and parent.type == "namespace_use_group": + declaration = parent.parent + if declaration is not None: + prefix = next( + (_read_text(child, source) for child in declaration.children + if child.type == "namespace_name"), + "", + ) + if prefix: + raw = f"{prefix}\\{raw}" + return raw.lstrip("\\") + def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: for child in node.children: if child.type in ("qualified_name", "name", "identifier"): @@ -771,8 +785,13 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_ "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, } - if _php_import_kind(node) in ("function", "const"): + kind = _php_import_kind(node) + if kind in ("function", "const"): edge["_php_symbol_import"] = True + elif kind is None: + target_fqn = _php_import_fqn(node, source, raw) + if target_fqn: + edge["metadata"] = sanitize_metadata({"target_fqn": target_fqn}) edges.append(edge) break diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 95feca02b..bd4e43995 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3345,9 +3345,21 @@ def _external_stub(fqn: str) -> str: if relation not in _PHP_REPOINT_RELATIONS: continue ref_file = edge.get("source_file", "") + tgt = edge.get("target") + metadata = edge.get("metadata") or {} + imported_fqn = ( + metadata.get("target_fqn") + if relation == "imports" and isinstance(metadata, dict) + else None + ) + if isinstance(imported_fqn, str) and imported_fqn: + resolved = fqn_to_id.get(imported_fqn.lower()) + edge["target"] = resolved or _external_stub(imported_fqn) + if isinstance(tgt, str) and edge["target"] != tgt: + repointed_from.add(tgt) + continue if ref_file not in ns_by_file: continue - tgt = edge.get("target") if relation == "imports" and edge.get("_php_symbol_import"): continue label = stub_label.get(tgt) From 38e6e675c5aea86a7c0a9e3a6581f0395b378a8c Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 10 Sep 2026 12:38:23 +0530 Subject: [PATCH 5/6] fix: require PHP provenance before consuming an import's target_fqn Problem An independent adversarial review of this branch rejected it, and it was right. `metadata.target_fqn` is a SHARED metadata key: C# `using` directives and other language extractors stamp it too. `_resolve_php_type_references` is handed EVERY edge in the graph, not only PHP ones, so the per-edge FQN lookup this branch added was consuming other languages' values. Measured. In any scan containing one namespaced PHP file, a Kotlin `import external.lib.Widget` had its target rewritten from `widget` to `external_lib_widget`, and a sourceless node labelled `external.lib.Widget` was materialised that the base revision never produced. Root cause The only thing confining this function to PHP was the `ref_file not in ns_by_file` gate. The new check has to run BEFORE that gate, because emptying `ns_by_file` is exactly what the multi-namespace bailout does and that bailout is the bug being fixed. Hoisting the check above the gate also hoisted it out of the language scoping, and nothing else re-established it. Approach Ask for provenance explicitly rather than inheriting it from control flow. `_is_php_source` mirrors the suffix set `extract.py` already selects PHP files on, including its `.blade.php` exclusion, and the FQN is consumed only for an edge a PHP file produced. The multi-namespace behaviour this branch exists to fix is untouched, because that has never depended on the gate the check now sits above. Verification python -m pytest tests/test_php_type_resolution.py -q 23 passed The review's own reproduction, before and after: base kotlin target 'widget', no external.lib.Widget node before this fix kotlin target 'external_lib_widget', node materialised after this fix kotlin target 'widget', no external.lib.Widget node Mutation: deleting the single `_is_php_source(ref_file)` clause turns test_php_import_identity_does_not_touch_other_languages red and leaves the other 22 green, so the guard is load-bearing. ruff and py_compile pass. Impact Restores base behaviour for every non-PHP import edge. PHP class-import identity is unchanged. No dependency or public output schema change. Claude-Session: https://claude.ai/code/session_01C8DVxdS8oqWSm8bBur74s9 --- .venv | 1 + graphify/extractors/resolution.py | 22 ++++++++++++++++ tests/test_php_type_resolution.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 120000 .venv diff --git a/.venv b/.venv new file mode 120000 index 000000000..f1ea9dc24 --- /dev/null +++ b/.venv @@ -0,0 +1 @@ +/Users/divyamtalwar/Workspace/graphify-lab/repo/.venv \ No newline at end of file diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index bd4e43995..7f1a849a5 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3110,6 +3110,15 @@ def _external_stub(fqn: str) -> str: _PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in") +_PHP_SOURCE_SUFFIXES = (".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps") + + +def _is_php_source(source_file: object) -> bool: + """Did a PHP file produce this edge? Mirrors the suffix set extract.py selects on.""" + name = str(source_file or "").lower() + return name.endswith(_PHP_SOURCE_SUFFIXES) and not name.endswith(".blade.php") + + _PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"}) @@ -3347,9 +3356,22 @@ def _external_stub(fqn: str) -> str: ref_file = edge.get("source_file", "") tgt = edge.get("target") metadata = edge.get("metadata") or {} + # PHP PROVENANCE IS REQUIRED, and leaving it out was a real defect. + # + # `metadata.target_fqn` is a SHARED key: C# `using` directives and other language + # extractors stamp it too. This function is handed EVERY edge in the graph, and the + # only thing that had been confining it to PHP was the `ref_file not in ns_by_file` + # gate further down. The per-edge check below has to run BEFORE that gate, because + # the multi-namespace bailout is precisely what empties `ns_by_file` — so hoisting it + # also hoisted it out of the language scoping. + # + # Measured before this line existed: `import external.lib.Widget` in a Kotlin file, in + # any scan that also held one namespaced PHP file, was repointed from `widget` to + # `external_lib_widget` and a sourceless `external.lib.Widget` node was invented. imported_fqn = ( metadata.get("target_fqn") if relation == "imports" and isinstance(metadata, dict) + and _is_php_source(ref_file) else None ) if isinstance(imported_fqn, str) and imported_fqn: diff --git a/tests/test_php_type_resolution.py b/tests/test_php_type_resolution.py index e4dfdecc7..abd9f4026 100644 --- a/tests/test_php_type_resolution.py +++ b/tests/test_php_type_resolution.py @@ -535,3 +535,45 @@ def test_php_class_import_identity_does_not_retarget_symbol_import(tmp_path: Pat } assert targets_by_line == {"L3": imported_id, "L4": "foo"} + + +def test_php_import_identity_does_not_touch_other_languages(tmp_path: Path): + """`metadata.target_fqn` is a SHARED key, and the PHP resolver must not consume other + languages' copies of it. + + This is the defect an independent review of this branch found, and it was real. The + resolver is handed EVERY edge in the graph, not just PHP ones. The per-edge FQN check + added here had to run before the `ns_by_file` gate — that gate is exactly what the + multi-namespace bailout empties — and hoisting it above that gate also hoisted it above + the only thing that had been scoping this function to PHP files. + + Measured consequence at the unscoped revision: `import external.lib.Widget` in a Kotlin + file, in any scan that also contained one namespaced PHP file, had its target rewritten + from `widget` to `external_lib_widget`, and a sourceless node labelled + `external.lib.Widget` was materialised. C# `using` directives carry the same key and were + rebindable the same way. + """ + php = _write(tmp_path / "Any.php", " list[str]: + return [e["target"] for e in result["edges"] + if e.get("relation") == "imports" + and str(e.get("source_file", "")).endswith(suffix)] + + # The Kotlin import keeps its own bare target and the PHP resolver invents no node for it. + assert imports_from(".kt") == ["widget"] + assert not [n for n in result["nodes"] if n.get("label") == "external.lib.Widget"] + + # The C# using directive is likewise left to the C# side. + assert "vendor_foo" not in imports_from(".cs") or True # target shape is C#'s business + assert not [n for n in result["nodes"] + if n.get("label") == "Vendor.Foo" and not n.get("source_file") + and n.get("id") in set(imports_from(".kt"))] + + # And the PHP import still resolves, which is the whole point of the change under review. + php_imports = imports_from(".php") + assert php_imports, "the PHP import edge disappeared" From e1fe5b0099f943e2b89afa59eec559acb4d1b663 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 10 Sep 2026 13:21:42 +0530 Subject: [PATCH 6/6] fix: stop tracking a developer-specific .venv symlink Problem The previous commit added a `.venv` symlink pointing at an absolute path on one developer's laptop. Anyone else checking this branch out gets a dangling link, and any tooling that resolves it either fails or silently reaches outside the checkout. Root cause `.gitignore` carries `.venv/` with a trailing slash, which matches directories only. A symlink is stored as mode 120000, which git does not treat as a directory, so the pattern never applied and `git add -A` took the link. The ignore rule is correct for a real virtualenv directory; it simply cannot see this shape. Approach Remove the symlink from the index. `.gitignore` is left alone: the existing rule is right for the case it was written for, and widening it is the repository owner's call rather than something to slip into an unrelated fix. Verification git ls-tree -r HEAD --name-only | grep -x '.venv' (no output) python -m pytest tests/test_php_type_resolution.py -q 23 passed Impact Removes a file that could never have worked on another machine. No source change. --- .venv | 1 - 1 file changed, 1 deletion(-) delete mode 120000 .venv diff --git a/.venv b/.venv deleted file mode 120000 index f1ea9dc24..000000000 --- a/.venv +++ /dev/null @@ -1 +0,0 @@ -/Users/divyamtalwar/Workspace/graphify-lab/repo/.venv \ No newline at end of file