fix(extract): close residual absolute-path/username leaks (#1899)
Completes #1789. Two residual leaks into committed graph.json: (a) Out-of-root reference targets (a .csproj ProjectReference, .sln project, or bash `source` pointing outside the scan root) kept an absolute source_file and an absolute-derived id, because the relativization post-passes only handled paths under root and silently left out-of-root ones absolute. They now get a portable walk-up relative source_file and an `ext_`-namespaced id (basename fallback for far-outside or cross-drive targets), with edge endpoints remapped. (b) A symbol whose name normalizes to nothing (minified `$`, a JSONC `"//"` key) collapsed _make_id(stem, name) to the bare absolute file stem, leaking the path and colliding with the file node. Guard at the three mint sites (json_config keys, engine function names) to skip these no-signal symbols. Adds regression tests: an out-of-root ProjectReference stays portable (no scan-path in any id/source_file/edge), and a minified `$` is dropped while the real function survives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e1b8a17dc8
commit
b3dc15b839
@@ -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.
|
||||
|
||||
+41
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>'
|
||||
'<TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>'
|
||||
)
|
||||
(web / "WebApi.csproj").write_text(
|
||||
'<Project Sdk="Microsoft.NET.Sdk"><ItemGroup>'
|
||||
'<ProjectReference Include="..\\Core\\Core.csproj" /></ItemGroup></Project>'
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user