From 25df580061abd7ce5679cc3ff27af5b1e0c8a427 Mon Sep 17 00:00:00 2001 From: Dennis Cornwell Date: Wed, 3 Jun 2026 16:03:06 -0400 Subject: [PATCH] fix: relativize manifest, .graphify_root, and cache source_file fields (#777) Fixes #777. Relativize manifest keys, .graphify_root, and cache source_file fields on persist; re-anchor on load. In-memory callers still see absolute paths. Symlink round-trip fixed in follow-up commit 8f09326. --- graphify/__main__.py | 4 +- graphify/cache.py | 94 ++++++++++++++++++++++- graphify/detect.py | 85 ++++++++++++++++++-- graphify/watch.py | 13 +++- tests/test_cache.py | 144 ++++++++++++++++++++++++++++++++++ tests/test_detect.py | 179 +++++++++++++++++++++++++++++++++++++++++++ tests/test_watch.py | 35 +++++++++ 7 files changed, 540 insertions(+), 14 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 2b25d7c..efd5619 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -4095,7 +4095,7 @@ def main() -> None: f"est. cost: ${cost:.4f}" ) try: - _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both") + _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) if global_merge: @@ -4184,7 +4184,7 @@ def main() -> None: } analysis_path.write_text(json.dumps(analysis, indent=2), encoding="utf-8") try: - _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both") + _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) diff --git a/graphify/cache.py b/graphify/cache.py index 2052cf7..407ae46 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -146,6 +146,70 @@ def file_hash(path: Path, root: Path = Path(".")) -> str: return digest +def _relativize_source_files_in(payload: dict, root: Path) -> None: + """Mutate ``payload`` to rewrite absolute ``source_file`` fields as + forward-slash relative paths from ``root``. + + Mirror of :func:`graphify.watch._relativize_source_files` so cached + extraction fragments persist in portable form (#777). Already-relative + fields and out-of-root paths pass through unchanged. + + Only ``root`` is resolved — ``source_file`` itself is relativized + symbolically so in-root symlinks keep their original name rather than + pointing at the resolved target. Same reasoning as + :func:`graphify.detect._to_relative_for_storage`. + """ + try: + root_resolved = Path(root).resolve() + except OSError: + return + for bucket in ("nodes", "edges", "hyperedges"): + for item in payload.get(bucket, []): + if not isinstance(item, dict): + continue + source = item.get("source_file") + if not source: + continue + sp = Path(source) + if not sp.is_absolute(): + continue + try: + rel = os.path.relpath(sp, root_resolved) + except (ValueError, OSError): + continue # out-of-root (e.g. Windows cross-drive) + if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"): + continue # escaped root — keep absolute + item["source_file"] = rel.replace(os.sep, "/") + + +def _absolutize_source_files_in(payload: dict, root: Path) -> None: + """Inverse of :func:`_relativize_source_files_in`. + + Re-anchor relative ``source_file`` fields against ``root`` so callers + that load a cached fragment see the same absolute-path shape that a + fresh in-process extraction would produce. Legacy cache entries with + absolute ``source_file`` values pass through unchanged. + """ + try: + root_resolved = Path(root).resolve() + except OSError: + return + for bucket in ("nodes", "edges", "hyperedges"): + for item in payload.get(bucket, []): + if not isinstance(item, dict): + continue + source = item.get("source_file") + if not source: + continue + sp = Path(source) + if sp.is_absolute(): + continue + try: + item["source_file"] = str(root_resolved / sp) + except (TypeError, OSError): + continue + + def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path: """Returns graphify-out/cache/{kind}/ - creates it if needed. @@ -176,17 +240,26 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | entry = cache_dir(root, kind) / f"{h}.json" if entry.exists(): try: - return json.loads(entry.read_text(encoding="utf-8")) + result = json.loads(entry.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None + # Re-anchor relative source_file fields so callers see the same + # absolute-path shape that a fresh in-process extraction produces + # (#777). Legacy entries with absolute source_file pass through. + if isinstance(result, dict): + _absolutize_source_files_in(result, root) + return result # Migration fallback: check legacy flat cache/ dir for AST entries if kind == "ast": legacy = Path(root).resolve() / _GRAPHIFY_OUT / "cache" / f"{h}.json" if legacy.exists(): try: - return json.loads(legacy.read_text(encoding="utf-8")) + result = json.loads(legacy.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None + if isinstance(result, dict): + _absolutize_source_files_in(result, root) + return result return None @@ -203,12 +276,27 @@ def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "a p = Path(path) if not p.is_file(): return + # Relativize source_file fields against ``root`` before write so the + # cache file on disk is portable across machines and checkout + # directories (#777). The cache key is content-hashed so lookup is + # already path-independent; this fixes the embedded path leak. + # + # Serialize a relativized copy rather than mutating the caller's dict — + # downstream pipeline steps (notably extract.py's AST prefix remap, which + # looks up Path(source_file).resolve() in a prefix table) depend on the + # source_file field's original absolute form. Mutating the input here would + # silently break those remaps on the first extraction pass. + on_disk = result + if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges")): + import copy as _copy + on_disk = _copy.deepcopy(result) + _relativize_source_files_in(on_disk, root) h = file_hash(p, root) target_dir = cache_dir(root, kind) entry = target_dir / f"{h}.json" fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp") try: - os.write(fd, json.dumps(result).encode()) + os.write(fd, json.dumps(on_disk).encode()) os.close(fd) try: os.replace(tmp_path, entry) diff --git a/graphify/detect.py b/graphify/detect.py index f770a3a..67dcf6e 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -1091,12 +1091,71 @@ def _md5_file(path: Path) -> str: return h.hexdigest() -def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict: - """Load the manifest from a previous run. Returns {} on any error.""" +def _to_relative_for_storage(key: str, root: Path) -> str: + """Return ``key`` as a forward-slash relative path from ``root``. + + Keys outside ``root`` (out-of-tree symlinked sources, external --include + paths) and already-relative keys pass through unchanged — mirrors the + fallback in :func:`graphify.watch._relativize_source_files` so the + on-disk artifact survives the round-trip even when some paths cannot be + portably encoded. + + Only ``root`` is resolved — the key itself is relativized symbolically + so an in-root symlink (e.g. ``alias.py -> sub/target.py``) is stored + under its own name. Resolving the key would point the stored entry at + the symlink target, and the original key would then miss on reload and + re-extract on every incremental run. + """ + p = Path(key) + if not p.is_absolute(): + return key try: - return json.loads(Path(manifest_path).read_text(encoding="utf-8")) + rel = os.path.relpath(p, Path(root).resolve()) + except (ValueError, OSError): + return key # outside root (e.g. Windows cross-drive) + # ``os.path.relpath`` happily produces ``../foo`` for paths outside + # root; mirror the prior ``relative_to``-raises-ValueError semantics by + # keeping out-of-root entries in their absolute form. + if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"): + return key + return rel.replace(os.sep, "/") + + +def _to_absolute_from_storage(key: str, root: Path) -> str: + """Inverse of :func:`_to_relative_for_storage`. + + Re-anchor a stored key against ``root``. Already-absolute keys + (legacy manifests, out-of-root entries) pass through unchanged so + that newly-loaded manifests from before this change remain readable. + Uses ``Path(root).resolve()`` so the produced absolute path matches + what :func:`detect` returns (which also resolves the scan root). + """ + p = Path(key) + if p.is_absolute(): + return str(p) + return str(Path(root).resolve() / p) + + +def load_manifest( + manifest_path: str = _MANIFEST_PATH, + *, + root: Path | None = None, +) -> dict: + """Load the manifest from a previous run. Returns {} on any error. + + When ``root`` is provided, stored relative keys are re-anchored against + it so callers see absolute paths regardless of on-disk format. Legacy + manifests with absolute keys pass through unchanged, so a graphify-out/ + written by an older version (or by a caller that didn't supply ``root`` + to :func:`save_manifest`) remains readable. + """ + try: + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) except Exception: return {} + if root is None or not isinstance(raw, dict): + return raw + return {_to_absolute_from_storage(k, root): v for k, v in raw.items()} def save_manifest( @@ -1104,6 +1163,7 @@ def save_manifest( manifest_path: str = _MANIFEST_PATH, *, kind: str = "both", + root: Path | None = None, ) -> None: """Save current file mtimes + content hashes for change detection. @@ -1113,8 +1173,14 @@ def save_manifest( kind="semantic" — written by `graphify extract` after semantic extraction. Stamps semantic_hash; preserves existing ast_hash. kind="both" — full pipeline: stamps both hashes (default). + + When ``root`` is provided, keys are relativized against it before write + (forward-slash, posix-style) so the on-disk manifest is portable across + machines and checkout locations (#777). Out-of-root entries are written + as absolute so they continue to round-trip on the saving machine. + When ``root`` is None the legacy absolute-keyed format is preserved. """ - existing = load_manifest(manifest_path) + existing = load_manifest(manifest_path, root=root) def _normalise_entry(entry): if isinstance(entry, (int, float)): @@ -1160,6 +1226,12 @@ def save_manifest( # Preserve semantic_hash only when content is unchanged entry["semantic_hash"] = prev.get("semantic_hash", "") if h == prev.get("ast_hash", "") else "" manifest[f] = entry + if root is not None: + # Persist in portable form: forward-slash relative paths. Keys outside + # ``root`` (out-of-tree symlinked corpora, --include sources) keep + # their absolute form so the manifest round-trips on the saving + # machine even when not every entry can be portably encoded. + manifest = {_to_relative_for_storage(k, root): v for k, v in manifest.items()} Path(manifest_path).parent.mkdir(parents=True, exist_ok=True) Path(manifest_path).write_text(json.dumps(manifest, indent=2), encoding="utf-8") @@ -1197,7 +1269,10 @@ def detect_incremental( contains at least one direct symlinked child, ``False`` otherwise. """ full = detect(root, follow_symlinks=follow_symlinks, google_workspace=google_workspace, extra_excludes=extra_excludes) - manifest = load_manifest(manifest_path) + # Pass ``root`` so a manifest written with relative keys (post-#777) is + # re-anchored to the absolute form the rest of this function compares + # against. Legacy absolute-keyed manifests pass through unchanged. + manifest = load_manifest(manifest_path, root=root) if not manifest: # No previous run - treat everything as new diff --git a/graphify/watch.py b/graphify/watch.py index 8a2c752..5f9c5f7 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -565,7 +565,12 @@ def _rebuild_code( _relativize_source_files(result, project_root) out.mkdir(exist_ok=True) - (out / ".graphify_root").write_text(str(watch_root), encoding="utf-8") + # Write the user-supplied path rather than the resolved absolute form + # so a committed ``graphify-out/.graphify_root`` is portable across + # clones and CI runners (#777). When ``watch_path`` is ``.`` (the + # common case for ``graphify update``), this writes ``.`` and the + # subsequent re-run resolves it against the caller's CWD. + (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8") if no_cluster: # Normalise to "links" key so schema is consistent with the full clustered path. @@ -595,7 +600,7 @@ def _rebuild_code( try: from graphify.detect import save_manifest - save_manifest(detected["files"], kind="ast") + save_manifest(detected["files"], kind="ast", root=project_root) except Exception: pass @@ -633,7 +638,7 @@ def _rebuild_code( if same_topology: try: from graphify.detect import save_manifest - save_manifest(detected["files"], kind="ast") + save_manifest(detected["files"], kind="ast", root=project_root) except Exception: pass flag = out / "needs_update" @@ -704,7 +709,7 @@ def _rebuild_code( try: from graphify.detect import save_manifest - save_manifest(detected["files"], kind="ast") + save_manifest(detected["files"], kind="ast", root=project_root) except Exception: pass diff --git a/tests/test_cache.py b/tests/test_cache.py index c3f19dd..1c4fbf2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -126,3 +126,147 @@ def test_body_content_no_frontmatter(): """_body_content returns content unchanged when no frontmatter present.""" content = b"No frontmatter here." assert _body_content(content) == content + + +# --- #777: portable cache source_file fields -------------------------------- +# ``save_cached`` relativizes ``source_file`` entries inside the cache file +# so a committed ``graphify-out/cache/`` is portable across machines and +# CI runners. ``load_cached`` re-absolutizes them so consumers (extract, +# merge into graph.json) see the same shape that fresh extraction emits. + +def test_save_cached_relativizes_source_file(tmp_path): + """The on-disk cache JSON contains forward-slash relative source_file + entries — no absolute prefix from the saving machine leaks in.""" + import json + from graphify.cache import save_cached, file_hash, cache_dir + + (tmp_path / "src").mkdir() + src = tmp_path / "src" / "foo.py" + src.write_text("def x(): pass\n") + abs_src = str(src.resolve()) + result = { + "nodes": [{"id": "n1", "label": "foo", "source_file": abs_src}], + "edges": [{"source": "n1", "target": "n1", "source_file": abs_src}], + } + save_cached(src, result, root=tmp_path, kind="ast") + + h = file_hash(src, tmp_path) + entry = cache_dir(tmp_path, "ast") / f"{h}.json" + on_disk = json.loads(entry.read_text(encoding="utf-8")) + node_sources = {n["source_file"] for n in on_disk["nodes"]} + edge_sources = {e["source_file"] for e in on_disk["edges"]} + assert node_sources == {"src/foo.py"}, ( + f"cache nodes must store relative source_file; got {node_sources}" + ) + assert edge_sources == {"src/foo.py"} + + +def test_load_cached_absolutizes_source_file(tmp_path): + """``load_cached`` returns the same absolute-path shape that a fresh + extraction produces, so consumers don't need to special-case cache + hits vs. fresh extraction.""" + from graphify.cache import save_cached, load_cached + + (tmp_path / "src").mkdir() + src = tmp_path / "src" / "foo.py" + src.write_text("def x(): pass\n") + abs_src = str(src.resolve()) + save_cached(src, { + "nodes": [{"id": "n1", "source_file": abs_src}], + "edges": [{"source": "n1", "target": "n1", "source_file": abs_src}], + }, root=tmp_path, kind="ast") + + loaded = load_cached(src, root=tmp_path, kind="ast") + assert loaded is not None + assert loaded["nodes"][0]["source_file"] == abs_src + assert loaded["edges"][0]["source_file"] == abs_src + + +def test_load_cached_passes_through_legacy_absolute_source_file(tmp_path): + """Cache entries written by an older graphify (with absolute source_file + inside) must still load correctly: the absolutize step is a no-op for + already-absolute values.""" + import json + from graphify.cache import load_cached, file_hash, cache_dir + + (tmp_path / "src").mkdir() + src = tmp_path / "src" / "foo.py" + src.write_text("pass\n") + abs_src = str(src.resolve()) + + # Hand-write a legacy-format cache entry (absolute source_file). + h = file_hash(src, tmp_path) + entry = cache_dir(tmp_path, "ast") / f"{h}.json" + entry.write_text(json.dumps({ + "nodes": [{"id": "n1", "source_file": abs_src}], + "edges": [], + })) + + loaded = load_cached(src, root=tmp_path, kind="ast") + assert loaded is not None + assert loaded["nodes"][0]["source_file"] == abs_src + + +def test_cache_portable_across_roots(tmp_path): + """End-to-end portability: a cache entry written at one root can be + consumed at a different absolute root because the file is content-hashed + AND its embedded source_file is stored relative.""" + import json + import shutil + from graphify.cache import save_cached, load_cached, file_hash, cache_dir + + repo_a = tmp_path / "repo_a" + repo_a.mkdir() + (repo_a / "src").mkdir() + src_a = repo_a / "src" / "foo.py" + src_a.write_text("def x(): pass\n") + save_cached(src_a, { + "nodes": [{"id": "n1", "source_file": str(src_a.resolve())}], + "edges": [], + }, root=repo_a, kind="ast") + + # Copy corpus + cache to a second location with a different absolute prefix. + repo_b = tmp_path / "repo_b" + shutil.copytree(repo_a, repo_b) + + src_b = repo_b / "src" / "foo.py" + loaded = load_cached(src_b, root=repo_b, kind="ast") + assert loaded is not None, ( + "cache must port across absolute prefixes (content hash + relative source_file)" + ) + # Source path re-anchored to the new root, not the old one. + assert loaded["nodes"][0]["source_file"] == str(src_b.resolve()) + assert not str(repo_a) in loaded["nodes"][0]["source_file"] + + +def test_save_cached_in_root_symlink_keeps_symlink_name(tmp_path): + """``source_file`` for an in-root symlink must be stored under the + symlink's own name, not the resolved target. Lower-impact than the + manifest case (cache lookup is content-hashed, not key-matched), but + keeps the on-disk shape consistent with what callers passed in.""" + import json + from graphify.cache import save_cached, file_hash, cache_dir + + (tmp_path / "sub").mkdir() + target = tmp_path / "sub" / "target.py" + target.write_text("pass\n") + alias = tmp_path / "alias.py" + try: + alias.symlink_to(target) + except (OSError, NotImplementedError): + import pytest + pytest.skip("filesystem does not support symlinks") + + abs_alias = str(alias) # caller's view — the symlink path, unresolved + save_cached(alias, { + "nodes": [{"id": "n1", "source_file": abs_alias}], + "edges": [], + }, root=tmp_path, kind="ast") + + h = file_hash(alias, tmp_path) + entry = cache_dir(tmp_path, "ast") / f"{h}.json" + on_disk = json.loads(entry.read_text(encoding="utf-8")) + assert on_disk["nodes"][0]["source_file"] == "alias.py", ( + f"cache must store symlink name, not resolved target; got " + f"{on_disk['nodes'][0]['source_file']!r}" + ) diff --git a/tests/test_detect.py b/tests/test_detect.py index 9008518..ee5ac1d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -1057,3 +1057,182 @@ def test_shebang_interpreter_env_vs_assignment_before_interpreter(tmp_path): script.write_bytes(b"#!/usr/bin/env -vS DEBUG=1 python3 -u\nprint('x')\n") assert _shebang_interpreter(script) == "python3" assert classify_file(script) == FileType.CODE + + +# --- #777: portable manifest paths ------------------------------------------ +# When ``root`` is supplied, the on-disk manifest stores forward-slash +# relative keys so a committed ``graphify-out/`` round-trips across machines +# and CI runners. In-memory the keys are still absolute, so internal callers +# (notably :func:`detect_incremental`) remain unchanged. + +def test_save_manifest_relativizes_keys_when_root_given(tmp_path): + """``save_manifest(root=...)`` writes forward-slash relative keys.""" + import json + from graphify.detect import save_manifest, load_manifest + + (tmp_path / "src").mkdir() + (tmp_path / "src" / "foo.py").write_text("def x(): pass\n") + (tmp_path / "doc.md").write_text("hello\n") + + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + files = { + "code": [str(tmp_path / "src" / "foo.py")], + "document": [str(tmp_path / "doc.md")], + } + save_manifest(files, manifest_path, root=tmp_path) + + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert set(raw) == {"src/foo.py", "doc.md"}, ( + f"on-disk keys must be relative posix paths, got {set(raw)}" + ) + + # Same file, loaded with root: callers see absolute keys back. + loaded = load_manifest(manifest_path, root=tmp_path) + abs_foo = str((tmp_path / "src" / "foo.py").resolve()) + abs_doc = str((tmp_path / "doc.md").resolve()) + assert set(loaded) == {abs_foo, abs_doc} + + +def test_save_manifest_without_root_keeps_absolute_keys(tmp_path): + """Back-compat: callers that don't pass ``root`` still get the legacy + absolute-keyed manifest format. Required so skill-generated scripts that + call ``save_manifest(detect['files'])`` keep working unchanged.""" + import json + from graphify.detect import save_manifest + + f = tmp_path / "foo.py" + f.write_text("pass\n") + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest({"code": [str(f)]}, manifest_path) + + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert list(raw)[0] == str(f.resolve()), ( + f"without root, keys must remain absolute; got {list(raw)}" + ) + + +def test_load_manifest_absolutizes_relative_keys(tmp_path): + """``load_manifest(root=...)`` re-anchors stored relative keys so the + in-memory shape matches what :func:`detect` returns.""" + import json + from graphify.detect import load_manifest + + manifest_path = tmp_path / "graphify-out" / "manifest.json" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text(json.dumps({ + "src/foo.py": {"mtime": 0.0, "ast_hash": "h1", "semantic_hash": ""}, + "doc.md": {"mtime": 0.0, "ast_hash": "h2", "semantic_hash": ""}, + })) + + loaded = load_manifest(str(manifest_path), root=tmp_path) + assert str((tmp_path / "src" / "foo.py").resolve()) in loaded + assert str((tmp_path / "doc.md").resolve()) in loaded + + +def test_load_manifest_passes_through_legacy_absolute_keys(tmp_path): + """Legacy absolute-keyed manifests still load correctly when ``root`` + is supplied — the absolutize step is a no-op for already-absolute keys.""" + import json + from graphify.detect import load_manifest + + manifest_path = tmp_path / "graphify-out" / "manifest.json" + manifest_path.parent.mkdir(parents=True) + abs_key = str((tmp_path / "foo.py").resolve()) + manifest_path.write_text(json.dumps({abs_key: {"mtime": 0.0, "ast_hash": "h", "semantic_hash": ""}})) + + loaded = load_manifest(str(manifest_path), root=tmp_path) + assert abs_key in loaded + + +def test_save_manifest_out_of_root_keeps_absolute(tmp_path): + """Files outside ``root`` (e.g. symlinked external corpora) are stored + absolute so they round-trip on the saving machine even when they can't + be portably encoded.""" + import json + from graphify.detect import save_manifest + + outside = tmp_path.parent / f"{tmp_path.name}-sibling.py" + outside.write_text("pass\n") + try: + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest({"code": [str(outside)]}, manifest_path, root=tmp_path) + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + key = list(raw)[0] + assert Path(key).is_absolute(), ( + f"out-of-root entries must keep absolute keys, got {key!r}" + ) + finally: + outside.unlink(missing_ok=True) + + +def test_detect_incremental_portable_across_paths(tmp_path): + """End-to-end: a manifest written at one root must be readable from a + different absolute prefix (the cross-machine case #777 is about). + Simulates two checkouts of the same corpus by hard-linking files into a + second tmp dir and comparing detection results.""" + import json + from graphify.detect import save_manifest, detect_incremental + + # First "machine": create corpus, save manifest with root. + repo_a = tmp_path / "repo_a" + repo_a.mkdir() + (repo_a / "src").mkdir() + (repo_a / "src" / "foo.py").write_text("pass\n") + (repo_a / "doc.md").write_text("hello\n") + + manifest_a = str(repo_a / "graphify-out" / "manifest.json") + files = { + "code": [str(repo_a / "src" / "foo.py")], + "document": [str(repo_a / "doc.md")], + } + save_manifest(files, manifest_a, root=repo_a) + + # Second "machine": copy the corpus + manifest to a different absolute path. + repo_b = tmp_path / "repo_b" + (repo_b / "src").mkdir(parents=True) + (repo_b / "src" / "foo.py").write_text("pass\n") + (repo_b / "doc.md").write_text("hello\n") + (repo_b / "graphify-out").mkdir() + manifest_b = repo_b / "graphify-out" / "manifest.json" + manifest_b.write_text(Path(manifest_a).read_text()) + + # Stat the copied files match the originals' content hash so + # detect_incremental should see zero new files. + inc = detect_incremental(repo_b, str(manifest_b)) + assert inc["new_total"] == 0, ( + f"manifest must port across absolute paths; got new_total={inc['new_total']}" + ) + + +def test_save_manifest_in_root_symlink_roundtrips(tmp_path): + """In-root symlinks must store under the symlink's own name, not the + resolved target. Resolving the key when relativizing pointed the stored + entry at ``sub/target.py`` instead of ``alias.py``, so the original + ``alias.py`` key missed on reload and re-extracted on every incremental + run.""" + import json + from graphify.detect import save_manifest, load_manifest + + (tmp_path / "sub").mkdir() + target = tmp_path / "sub" / "target.py" + target.write_text("pass\n") + alias = tmp_path / "alias.py" + try: + alias.symlink_to(target) + except (OSError, NotImplementedError): + import pytest + pytest.skip("filesystem does not support symlinks") + + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest({"code": [str(alias)]}, manifest_path, root=tmp_path) + + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert "alias.py" in raw, ( + f"in-root symlink must be stored under its own name, got {list(raw)}" + ) + assert "sub/target.py" not in raw, ( + f"symlink must not be stored under resolved target path; got {list(raw)}" + ) + + loaded = load_manifest(manifest_path, root=tmp_path) + assert str(tmp_path.resolve() / "alias.py") in loaded diff --git a/tests/test_watch.py b/tests/test_watch.py index 80dff31..76604db 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -141,6 +141,41 @@ def test_rebuild_lock_does_not_accumulate_pids_across_runs(tmp_path): assert not lock_path.exists() +def test_graphify_root_preserves_relative_when_invoked_with_relative_path(tmp_path, monkeypatch): + """#777: ``.graphify_root`` stores the user-supplied path (``.``), not the + resolved absolute, so a committed ``graphify-out/.graphify_root`` is + portable across clones and CI runners.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "lib.py").write_text("def f(): pass\n", encoding="utf-8") + + monkeypatch.chdir(corpus) + assert _rebuild_code(Path("."), acquire_lock=False) is True + + saved = (corpus / "graphify-out" / ".graphify_root").read_text(encoding="utf-8") + assert saved == ".", ( + f".graphify_root must preserve the user-supplied path; got {saved!r}" + ) + + +def test_graphify_root_preserves_absolute_when_user_supplied(tmp_path): + """When the caller supplies an absolute path, ``.graphify_root`` stores + that absolute form verbatim — preserving explicit-absolute intent.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "lib.py").write_text("def f(): pass\n", encoding="utf-8") + assert _rebuild_code(corpus, acquire_lock=False) is True + + saved = (corpus / "graphify-out" / ".graphify_root").read_text(encoding="utf-8") + assert saved == str(corpus), ( + f"absolute caller path must be preserved as-is; got {saved!r}" + ) + + def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path): """#1007: graphify update (_rebuild_code with no changed_paths) must remove nodes and edges from files deleted since the last run."""