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