Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Fix: the `definition_file` recorded on a merged C/C++/Objective-C decl/def node is now stored repo-relative like its sibling `source_file`, instead of keeping the build machine's absolute path — every path-normalizing pass keyed on `source_file` alone, so a graph shipped the builder's filesystem layout and `get_node`'s `Defined in:` line named a path that does not exist on any other checkout (#3223).

## 0.9.53 (2026-08-30)

- Fix: a batch of cross-language inheritance-edge corrections (thanks @Synvoya): JavaScript `class X extends Y` now emits an `inherits` edge (#1790); PHP interfaces, enums, and traits are captured as class-like nodes with their heritage (#1791); Scala `trait` declarations become class-like nodes (#1792) and qualified `extends`/`with` bases resolve to the tail type (#1794); a qualified Kotlin supertype resolves to its tail type instead of the package head (#1793); a C# interface extending an interface is classified as `inherits`, not `implements` (#1817); and a Go interface type-set constraint no longer emits a spurious `embeds` edge (#1818).
Expand Down
5 changes: 5 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
continue
if "source_file" in node:
node["source_file"] = _norm_source_file(node["source_file"], _root)
# A merged C/C++/ObjC decl/def node also carries the definition's
# file; it is a source path like any other and must be relativized
# too, or the graph ships the build machine's absolute path.
if "definition_file" in node:
node["definition_file"] = _norm_source_file(node["definition_file"], _root)
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
node_set = set(G.nodes())

Expand Down
47 changes: 28 additions & 19 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,22 +318,30 @@ def _changed_path_candidates(raw: Path, *, change_root: Path, watch_root: Path)
return candidates


# Every stored path that names a file in the scanned tree. ``definition_file``
# is the implementation site recorded when a C/C++/ObjC declaration and its
# definition merge into one node; it must stay repo-relative like its sibling
# ``source_file`` so a graph built on one machine reads on another.
_PORTABLE_PATH_KEYS = ("source_file", "definition_file")


def _relativize_source_files(payload: dict, root: Path, *, scope: Path | None = None) -> None:
for bucket in ("nodes", "edges", "hyperedges"):
for item in payload.get(bucket, []):
source = item.get("source_file")
if not source:
continue
source_path = Path(source)
if not source_path.is_absolute():
continue
try:
resolved = source_path.resolve()
if scope is not None and not _is_relative_to(resolved, scope):
for key in _PORTABLE_PATH_KEYS:
source = item.get(key)
if not source:
continue
source_path = Path(source)
if not source_path.is_absolute():
continue
try:
resolved = source_path.resolve()
if scope is not None and not _is_relative_to(resolved, scope):
continue
item[key] = resolved.relative_to(root).as_posix()
except ValueError:
continue
item["source_file"] = resolved.relative_to(root).as_posix()
except ValueError:
continue


def _rebase_relative_source_files(payload: dict, source_root: Path, target_root: Path) -> None:
Expand All @@ -342,13 +350,14 @@ def _rebase_relative_source_files(payload: dict, source_root: Path, target_root:
return
for bucket in ("nodes", "edges", "hyperedges"):
for item in payload.get(bucket, []):
source = item.get("source_file")
if not source or Path(source).is_absolute():
continue
try:
item["source_file"] = (source_root / source).relative_to(target_root).as_posix()
except ValueError:
continue
for key in _PORTABLE_PATH_KEYS:
source = item.get(key)
if not source or Path(source).is_absolute():
continue
try:
item[key] = (source_root / source).relative_to(target_root).as_posix()
except ValueError:
continue


class _StoredSourcePaths:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,36 @@ def test_norm_source_file_relativizes_a_posix_absolute_path():
) == "docs/api/README.md"


def test_build_from_json_relativizes_definition_file():
"""A merged C/C++/ObjC decl/def node records where the symbol is implemented
in `definition_file`. That is a path into the scanned tree just like
`source_file`, so the graph must store it repo-relative — otherwise the
build machine's absolute path ships in graph.json and a reader on another
checkout (or the MCP `get_node` answer) points at a file that is not there."""
from graphify.build import build_from_json

root = "/home/ci/build/repo"
extraction = {
"nodes": [{
"id": "foo_bar",
"label": "bar",
"type": "function",
"file_type": "code",
"_origin": "ast",
"source_file": f"{root}/src/Foo.h",
"source_location": "L10",
"definition_file": f"{root}/src/Foo.cpp",
"definition_location": "L42",
}],
"edges": [],
}
G = build_from_json(extraction, root=root)
assert G.nodes["foo_bar"]["source_file"] == "src/Foo.h"
assert G.nodes["foo_bar"]["definition_file"] == "src/Foo.cpp"
# the line number is a plain string and must survive untouched
assert G.nodes["foo_bar"]["definition_location"] == "L42"


def test_derive_prune_root_recovers_root_from_posix_absolute_prune_sources():
"""The prune-root recovery skips any prune source it thinks is relative.

Expand Down
59 changes: 59 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4203,3 +4203,62 @@ def test_markdown_reconcile_does_not_suffix_match_top_level_target(tmp_path):
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
assert not any(edge.get("relation") == "references" for edge in links)


# --- portable paths: definition_file travels with source_file --------------

def test_relativize_source_files_relativizes_definition_file(tmp_path):
"""`definition_file` (the implementation site recorded when a C/C++/ObjC
decl/def pair merges) names a file in the scanned tree exactly like
`source_file`, so it must be relativized too. Left absolute, the graph
carries the build machine's paths and cannot be read on another checkout."""
from graphify.watch import _relativize_source_files

root = tmp_path.resolve()
payload = {"nodes": [{
"id": "foo_bar",
"source_file": str(root / "src" / "Foo.h"),
"definition_file": str(root / "src" / "Foo.cpp"),
}]}
_relativize_source_files(payload, root)
node = payload["nodes"][0]
assert node["source_file"] == "src/Foo.h"
assert node["definition_file"] == "src/Foo.cpp"


def test_relativize_source_files_leaves_an_outside_definition_file_alone(tmp_path):
"""The scope guard applies to the new key as well: a path outside the
watched tree is left as-is rather than being forced under the root."""
from graphify.watch import _relativize_source_files

root = (tmp_path / "repo").resolve()
(root).mkdir()
outside = (tmp_path / "elsewhere" / "Foo.cpp").resolve()
payload = {"nodes": [{
"id": "foo_bar",
"source_file": str(root / "Foo.h"),
"definition_file": str(outside),
}]}
_relativize_source_files(payload, root, scope=root)
node = payload["nodes"][0]
assert node["source_file"] == "Foo.h"
assert node["definition_file"] == str(outside)


def test_rebase_relative_source_files_rebases_definition_file(tmp_path):
"""Cache-root-relative rebasing moves both keys, so a decl/def node built
under a cache root keeps a definition site that resolves from the project
root instead of pointing one directory level off."""
from graphify.watch import _rebase_relative_source_files

source_root = tmp_path / "cache" / "pkg"
target_root = tmp_path / "cache"
payload = {"nodes": [{
"id": "foo_bar",
"source_file": "src/Foo.h",
"definition_file": "src/Foo.cpp",
}]}
_rebase_relative_source_files(payload, source_root, target_root)
node = payload["nodes"][0]
assert node["source_file"] == "pkg/src/Foo.h"
assert node["definition_file"] == "pkg/src/Foo.cpp"
Loading