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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+91
-3
@@ -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)
|
||||
|
||||
+80
-5
@@ -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
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user