diff --git a/CHANGELOG.md b/CHANGELOG.md
index f57b168..b276d1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.16 (unreleased)
+- Fix: close two residual paths where an absolute scan path (including the OS username) still leaked into a committed `graph.json`, completing #1789 (#1899). (a) A reference target outside the scan root (an out-of-root `.csproj` ProjectReference, `.sln` project, or bash `source`) kept its absolute `source_file` and an absolute-derived id, because the relativization post-passes silently skipped anything `relative_to(root)` could not handle; such targets now get a portable walk-up relative path and an `ext_`-namespaced id (bare basename when the target is far outside the corpus or on another drive). (b) A symbol whose name normalizes to nothing (a minified `$` function, a JSONC `"//"` comment key) collapsed `_make_id(stem, name)` down to the bare absolute file stem; those no-signal symbols are now skipped at mint time.
+
- Fix: uppercase TypeScript extensions (`.TS`/`.TSX`/`.MTS`/`.CTS`) are now parsed with the TypeScript grammar instead of falling through to the JavaScript grammar, which silently dropped interfaces and type aliases (#1881, thanks @xkam7ar). Detection and dispatch already lowercased, but the grammar selection inside `extract_js` compared the suffix case-sensitively.
- Fix: Kotlin builtin/stdlib types (`String`, `Int`, `List`, ...) are no longer emitted as `references` edges, matching the existing Java/Python/Go builtin filtering (#1876, thanks @kebwlmbhee). They created false coupling and split clusters on real projects. User types that legitimately share a name (`Result`, framework types) are deliberately not filtered, consistent with the other languages.
diff --git a/graphify/extract.py b/graphify/extract.py
index 51e2490..d5faa52 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -4891,7 +4891,31 @@ def extract(
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
run_language_resolvers(paths, per_file, all_nodes, all_edges)
- # Relativize source_file fields so paths are portable across machines (#555)
+ # Relativize source_file fields so paths are portable across machines (#555).
+ # A target OUTSIDE the scan root (an out-of-root ProjectReference/.sln/bash
+ # `source`) can't be made relative to root; leaving it absolute leaked the
+ # scan path including the OS username into a committed graph.json (#1899).
+ # Fall back to a walk-up relative form, or the bare basename when that would
+ # still embed foreign path segments (a far-away or cross-drive target). When
+ # the node's id was itself minted from the absolute path, remap it to a
+ # portable id and rewrite the edge endpoints that reference it.
+ def _portable_out_of_root_sf(p: Path) -> str:
+ try:
+ rel = os.path.relpath(str(p), str(root)).replace("\\", "/")
+ except ValueError:
+ return p.name # different Windows drive: no relative path exists
+ updepth = 0
+ for seg in rel.split("/"):
+ if seg == "..":
+ updepth += 1
+ else:
+ break
+ # More than a couple of walk-ups means the target lives well outside the
+ # corpus; its ancestor dirs would embed foreign (possibly user-named)
+ # segments, so collapse to the basename.
+ return p.name if updepth > 3 else rel
+
+ ext_id_remap: dict[str, str] = {}
for item in all_nodes + all_edges:
sf = item.get("source_file")
if not sf:
@@ -4901,8 +4925,24 @@ def extract(
continue
try:
item["source_file"] = sf_path.relative_to(root).as_posix()
+ continue
except ValueError:
pass
+ portable = _portable_out_of_root_sf(sf_path)
+ # A node whose id was minted from this absolute path also leaks it.
+ if "id" in item and item.get("id") == _make_id(str(sf_path)):
+ ext_id_remap[item["id"]] = _make_id("ext", portable)
+ item["source_file"] = portable
+
+ if ext_id_remap:
+ for n in all_nodes:
+ if n.get("id") in ext_id_remap:
+ n["id"] = ext_id_remap[n["id"]]
+ for e in all_edges:
+ if e.get("source") in ext_id_remap:
+ e["source"] = ext_id_remap[e["source"]]
+ if e.get("target") in ext_id_remap:
+ e["target"] = ext_id_remap[e["target"]]
# origin_file is an internal disambiguation hint (#1462): the colliding-id pass
# above reads it to keep same-named cross-file stubs distinct, after which nothing
diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py
index 723bef7..edc624a 100644
--- a/graphify/extractors/engine.py
+++ b/graphify/extractors/engine.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import importlib
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
+from graphify.ids import normalize_id
from graphify.extractors.models import LanguageConfig
from graphify.extractors.resolution import _resolve_js_import_target
from graphify.security import sanitize_metadata
@@ -1760,6 +1761,11 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
if name_node:
func_name = _read_text(name_node, source)
line = child.start_point[0] + 1
+ # A name that normalizes to nothing (e.g. minified `$`)
+ # would collapse the id to the absolute file-stem and
+ # leak the scan path (#1899); skip it (no graph signal).
+ if not normalize_id(func_name):
+ continue
func_nid = _make_id(stem, func_name)
add_node_fn(func_nid, f"{func_name}()", line)
add_edge_fn(file_nid, func_nid, "contains", line)
@@ -3124,6 +3130,11 @@ def _extract_generic(
if not func_name:
return
+ # A name that normalizes to nothing collapses `_make_id(prefix, name)`
+ # onto the (absolute-path-derived) prefix, leaking the scan path and
+ # colliding with the file/class node (#1899). No graph signal; skip.
+ if not normalize_id(func_name):
+ return
line = node.start_point[0] + 1
if parent_class_nid:
diff --git a/graphify/extractors/json_config.py b/graphify/extractors/json_config.py
index 90dab91..6a9b641 100644
--- a/graphify/extractors/json_config.py
+++ b/graphify/extractors/json_config.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id, _read_text
+from graphify.ids import normalize_id
_CONFIG_JSON_NAMES = frozenset({
@@ -140,6 +141,12 @@ def extract_json(path: Path) -> dict:
key = _key_text(child)
if not key:
continue
+ # A key that normalizes to nothing (a JSONC `"//"` comment key, say)
+ # would collapse `_make_id(stem, key)` down to the bare file-stem id,
+ # which is absolute-path-derived and leaks the scan path (#1899). Such
+ # keys carry no graph signal, so drop them.
+ if not normalize_id(key):
+ continue
key_nid = _make_id(stem, *(([parent_key] if parent_key else []) + [key]))
if not key_nid:
continue
diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py
index 37e0ba5..fac4fd0 100644
--- a/tests/test_dotnet.py
+++ b/tests/test_dotnet.py
@@ -126,6 +126,35 @@ def test_csproj_project_references():
assert len(imports) == 6 # 4 packages + 2 project refs
+def test_csproj_out_of_root_reference_id_is_portable(tmp_path):
+ """#1899: a ProjectReference to a project OUTSIDE the scan root must not leak
+ the absolute scan path (including the OS username) into the node id or
+ source_file. The out-of-root target gets a portable, `ext_`-namespaced id and
+ a walk-up relative source_file rather than the absolute-derived form."""
+ web = tmp_path / "WebApi"; web.mkdir()
+ core = tmp_path / "Core"; core.mkdir()
+ (core / "Core.csproj").write_text(
+ ''
+ 'net8.0'
+ )
+ (web / "WebApi.csproj").write_text(
+ ''
+ ''
+ )
+ result = extract([web / "WebApi.csproj"], cache_root=web)
+ marker = str(tmp_path)
+ for n in result["nodes"]:
+ assert marker not in n["id"], f"absolute path leaked into id: {n}"
+ assert marker not in (n.get("source_file") or ""), f"leaked into source_file: {n}"
+ for e in result["edges"]:
+ for f in ("source", "target", "source_file"):
+ assert marker not in str(e.get(f, "")), f"leaked into edge {f}: {e}"
+ core_ref = [n for n in result["nodes"] if "core" in n["id"].lower()]
+ assert core_ref, "out-of-root Core reference node missing"
+ assert core_ref[0]["id"].startswith("ext_")
+ assert core_ref[0]["source_file"] == "../Core/Core.csproj"
+
+
def test_csproj_target_framework():
r = extract_csproj(FIXTURES / "sample.csproj")
assert "net8.0" in _labels(r)
diff --git a/tests/test_extract.py b/tests/test_extract.py
index d588b21..32c8e8e 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -970,6 +970,23 @@ def test_python_qualified_class_method_call_resolves_extracted(tmp_path):
assert call_edges[0]["confidence"] == "EXTRACTED"
+def test_degenerate_symbol_name_does_not_leak_absolute_id(tmp_path):
+ """#1899 variant B: a symbol whose name normalizes to nothing (a minified `$`
+ function, a JSONC `"//"` key) must not be minted — `_make_id(stem, "")`
+ collapses to the bare, absolute-path-derived file stem, leaking the scan path
+ and colliding with the file node. Such nodes carry no graph signal."""
+ (tmp_path / "vendor.js").write_text(
+ "function $(){return 1}\nfunction real(){return 2}\n", encoding="utf-8"
+ )
+ result = extract([tmp_path / "vendor.js"], cache_root=tmp_path)
+ marker = str(tmp_path)
+ for n in result["nodes"]:
+ assert marker not in n["id"], f"absolute path leaked into id: {n}"
+ labels = {n.get("label") for n in result["nodes"]}
+ assert "real()" in labels, "the real function must still be extracted"
+ assert "$()" not in labels, "the degenerate `$` symbol must be dropped (#1899)"
+
+
def test_python_module_qualified_call_resolves_extracted(tmp_path):
"""`module.func()` where `module` is imported resolves to the callable that
module contains, with an EXTRACTED `calls` edge (#1883). A lowercase module