fix: relativize manifest, .graphify_root, and cache source_file fields (#777) - #1125
Conversation
Graphify-Labs#777) Three artifacts under graphify-out/ embedded absolute paths from the saving machine, breaking cross-machine portability of a committed graphify-out/: 1. manifest.json keys -- absolute paths => detect_incremental missed on every other checkout, forcing a full rebuild on every CI run. 2. .graphify_root -- absolute path => graphify update with no args scanned a non-existent path or failed outright after clone. 3. cache/ast/*.json source_file fields -- absolute paths inside cached extraction fragments, leaking user paths into committed diffs. graph.json was already relativized via watch._relativize_source_files (since 0.5.0), proving the pattern. This change mirrors that approach for the other three artifacts: relativize on persist, re-anchor against root on load. In-memory callers continue to see absolute paths, so internal code (notably the AST symbol-prefix remap in extract.py that depends on Path(source_file).resolve()) is unchanged. Out-of-root entries (symlinked external corpora) keep their absolute form so they round-trip on the saving machine even when they can't be portably encoded. Changes: * detect.py: save_manifest/load_manifest accept an optional root= kwarg. When provided, write relative posix keys, read them back as absolute. Legacy absolute-keyed manifests pass through unchanged. detect_incremental forwards its root arg so the bug fix is on by default for the common path. Callers that don't pass root keep the old behavior (skill-generated subagent scripts still work). * cache.py: save_cached serializes a deepcopy with relative source_file fields, leaving the caller's dict in its original absolute shape so downstream pipeline steps (AST prefix remap, graph merge) still see what they expect. load_cached re-anchors relative source_file fields back to absolute on read. * watch.py: .graphify_root stores the user-supplied watch_path rather than the resolved absolute, so a committed graphify-out/.graphify_root with "." works in any clone where the caller's CWD is the project root. Absolute paths are preserved as-is. save_manifest calls pass root=project_root. * __main__.py: extract's two save_manifest calls pass root=target. Tests: * test_save_manifest_relativizes_keys_when_root_given * test_save_manifest_without_root_keeps_absolute_keys (back-compat) * test_load_manifest_absolutizes_relative_keys * test_load_manifest_passes_through_legacy_absolute_keys (back-compat) * test_save_manifest_out_of_root_keeps_absolute * test_detect_incremental_portable_across_paths (end-to-end) * test_save_cached_relativizes_source_file * test_load_cached_absolutizes_source_file * test_load_cached_passes_through_legacy_absolute_source_file * test_cache_portable_across_roots (end-to-end) * test_graphify_root_preserves_relative_when_invoked_with_relative_path * test_graphify_root_preserves_absolute_when_user_supplied Closes Graphify-Labs#777. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
af0a5b8 to
98c623b
Compare
safishamsi
left a comment
There was a problem hiding this comment.
Thanks for this — the design is solid and the deepcopy rationale is well-documented. The relativize/absolutize round-trip, migration path, and back-compat behaviour all check out. Merge-ready modulo one issue below.
Minor issue: _to_relative_for_storage over-resolves symlinked files
In detect.py, _to_relative_for_storage calls .resolve() on the file key before relative_to(root):
return p.resolve().relative_to(Path(root).resolve()).as_posix()detect() emits unresolved keys (from os.walk), so a symlinked file inside the root — e.g. alias.py → sub/target.py — gets stored under key sub/target.py instead of alias.py. On reload, alias.py is not found in the manifest → it gets re-extracted on every incremental run. Not a corruption risk, but a redundant full-extract-per-file regression for any repo that uses in-root symlinks.
The fix is to relativize without resolving the file:
try:
return str(Path(os.path.relpath(p, Path(root).resolve()))).replace(os.sep, "/")
except (ValueError, OSError):
return key # out-of-root or Windows cross-driveSame issue exists in _relativize_source_files_in in cache.py (lower-impact there since cache lookup is content-hashed, not key-matched, but worth fixing for consistency).
Ideally also add a regression test:
def test_save_manifest_in_root_symlink_roundtrips(tmp_path):
target = tmp_path / "sub" / "target.py"
target.parent.mkdir()
target.write_text("x")
link = tmp_path / "alias.py"
link.symlink_to(target)
manifest = {str(link): {"mtime": 1.0, "size": 1}}
save_manifest(manifest, root=tmp_path)
loaded = load_manifest(root=tmp_path)
assert str(link.resolve()) in loaded or str(link) in loadedEverything else looks good — happy to merge once this is addressed.
…aphify-Labs#777) `_to_relative_for_storage` (detect.py) and `_relativize_source_files_in` (cache.py) called `.resolve()` on the file key before `relative_to(root)`, so an in-root symlink — e.g. `alias.py -> sub/target.py` — was stored under the resolved target's path (`sub/target.py`) instead of `alias.py`. `detect()` emits unresolved keys (from `os.walk`), so on the next incremental run the `alias.py` key missed the manifest lookup → that file re-extracted on every run. Not a correctness break, but a redundant full-extract-per-symlink regression. Switch to `os.path.relpath` against a resolved root only. The key itself stays symbolic, so symlinks round-trip under their own name. Preserve the prior out-of-root semantics by falling back to the absolute key when the computed relative path escapes root (`..` prefix). Same fix applied to `cache.py` for consistency — lower-impact there since cache lookup is content-hashed, not key-matched. Adds two regression tests covering the in-root symlink round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed in 8f09326. Switched Two regression tests added:
Both skip cleanly on filesystems that don't support symlinks. Full suite: 1678 passed, same two pre-existing AWS-creds-in-env failures ( |
Summary
Fixes #777. Three artifacts under
graphify-out/embedded absolute paths from the saving machine, breaking cross-machine portability of a committedgraphify-out/:manifest.jsonkeys — absolute paths ⇒detect_incrementalmissed on every other checkout, forcing a full rebuild on every CI run..graphify_root— absolute path ⇒graphify update(no args) scanned a non-existent path or failed outright after clone.cache/ast/*.jsonsource_filefields — absolute paths inside cached extraction fragments, leaking user paths into committed diffs.graph.jsonwas already relativized viawatch._relativize_source_files(since 0.5.0), proving the pattern. This PR mirrors that approach for the other three artifacts.Approach
Relativize on persist, re-anchor against
rooton load. In-memory callers continue to see absolute paths, so internal code is unchanged — notably the AST symbol-prefix remap inextract.py(added by #1033/#1096) that depends onPath(source_file).resolve()to look up its prefix table. Out-of-root entries (symlinked external corpora) keep their absolute form so they round-trip on the saving machine even when they can't be portably encoded.Changes
detect.py:save_manifest/load_manifestaccept an optionalroot=kwarg.detect_incrementalforwards itsrootarg so the fix is on by default for the common path.rootkeep the old behavior — skill-generated subagent scripts (tools/skillgen/expected/**/update.mdetc.) that callsave_manifest(incremental['files'])remain green.cache.py:save_cachedserializes adeepcopywith relativesource_filefields, leaving the caller's dict in its original absolute shape so downstream pipeline steps (AST prefix remap, graph merge) still see what they expect.load_cachedre-anchors relativesource_filefields back to absolute on read.watch.py:.graphify_rootstores the user-suppliedwatch_pathrather than the resolved absolute, so a committedgraphify-out/.graphify_rootwith.works in any clone where the caller's CWD is the project root. Absolute caller paths are preserved as-is. The threesave_manifestcalls passroot=project_root.__main__.py:extract's two_save_manifestcalls passroot=target.Why deepcopy in
save_cached?I initially mutated the caller's
resultdict in-place to relativize source_file fields. Two existing tests then failed (test_rebuild_code_is_idempotent_when_cluster_ids_flap,test_rebuild_code_skips_cluster_when_topology_unchanged). The cause: the symbol-prefix remap atextract.py:10885callsPath(sf).resolve()to look up the prefix table. With my mutation, that lookup gotPath("app.py").resolve()which resolved against CWD instead of the project root, missing the table → IDs stayed in their absolute-prefixed form → topology comparison saw a fake change on the second build → infinite-rebuild loop.deepcopyadds bounded overhead (one file's worth of nodes/edges per cache entry; typically a few KB, occasionally a few MB) and avoids the trap of leaking persistence concerns into the in-memory shape that other pipeline stages depend on.Tests
12 new tests:
test_save_manifest_relativizes_keys_when_root_giventest_save_manifest_without_root_keeps_absolute_keys(back-compat)test_load_manifest_absolutizes_relative_keystest_load_manifest_passes_through_legacy_absolute_keys(back-compat)test_save_manifest_out_of_root_keeps_absolutetest_detect_incremental_portable_across_paths(end-to-end)test_save_cached_relativizes_source_filetest_load_cached_absolutizes_source_filetest_load_cached_passes_through_legacy_absolute_source_file(back-compat)test_cache_portable_across_roots(end-to-end)test_graphify_root_preserves_relative_when_invoked_with_relative_pathtest_graphify_root_preserves_absolute_when_user_suppliedFull test suite: 1676 passed, 135 skipped (platform-dependent), 1 deselected (wheel build test needs
pip install build), 0 failures attributable to this change. Two test failures unrelated to this PR appear whenboto3/ AWS credentials are present in the env (test_ollama.py::test_detect_backend_*) — they assume no backend is configured. Both pass in a stripped env.Backwards compatibility
source_filestill load — same pattern.roottosave_manifest/load_manifestkeep their existing behavior.root(this PR updates all internal callers in__main__.pyandwatch.py).Test plan
pytest tests/test_detect.py tests/test_cache.py tests/test_watch.py— 161 passed, 2 skippedpytestsuite — 1676 passed, 0 unrelated failuresgraphify update ., verifymanifest.jsonkeys are relative posix pathsgraphify-out/to a different absolute prefix, rungraphify update ., verifydetect_incrementalreports zero new files (cache portable)Related: #722 (manifest absolute paths — original question issue), #952 (AST ID collisions — separate concern, not addressed here).
🤖 Generated with Claude Code