mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-23 05:55:54 +00:00
fix(cache): key file_hash stat-index memo by salt, not absolute path (#1989)
The digest salts content with the path relative to root (portability, #1774), but the stat-index memo was keyed by absolute path only — so the same file hashed under two roots (which happens within one `--out` run) served whichever digest was computed first, making file_hash order-dependent and poisoning the persisted stat-index across runs. Store one digest per salt under a "hashes" map; legacy un-salted "hash" entries are never trusted (recompute once). Digest computation is byte-identical, so existing cache entries still hit. 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
689dd6ccfd
commit
995508c328
+32
-12
@@ -278,16 +278,34 @@ def file_hash(path: Path, root: Path = Path("."), cache_root: "Path | None" = No
|
||||
# graphify-out/cache/stat-index.json inside the analyzed source tree even when
|
||||
# the AST cache itself is redirected to CWD (#1774 completion).
|
||||
_ensure_stat_index(root, cache_root=cache_root)
|
||||
abs_key = str(p.resolve())
|
||||
resolved = p.resolve()
|
||||
abs_key = str(resolved)
|
||||
# The salt is the path component that enters the digest (relative to root, or
|
||||
# the absolute-path fallback). The stat-index memo MUST be keyed by it too:
|
||||
# the same file hashed under two different roots yields two different digests
|
||||
# (this happens within one `--out` run), and a memo keyed only by absolute
|
||||
# path served whichever was computed first — making file_hash order-dependent
|
||||
# and poisoning the persisted stat-index across runs (#1989). Store one digest
|
||||
# per salt so alternating roots don't force re-reads.
|
||||
try:
|
||||
salt = resolved.relative_to(Path(root).resolve()).as_posix().lower()
|
||||
except ValueError:
|
||||
salt = resolved.as_posix().lower()
|
||||
|
||||
st: "os.stat_result | None" = None
|
||||
try:
|
||||
st = p.stat()
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry
|
||||
and entry.get("hash") is not None # word-count-only entries carry no hash
|
||||
if (isinstance(entry, dict)
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns):
|
||||
return entry["hash"]
|
||||
hashes = entry.get("hashes")
|
||||
if isinstance(hashes, dict):
|
||||
cached = hashes.get(salt)
|
||||
if isinstance(cached, str):
|
||||
return cached
|
||||
# Legacy single-digest entries ("hash") don't record which salt
|
||||
# produced them, so they are never trusted (#1989) — recompute once.
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -296,21 +314,23 @@ def file_hash(path: Path, root: Path = Path("."), cache_root: "Path | None" = No
|
||||
h = hashlib.sha256()
|
||||
h.update(content)
|
||||
h.update(b"\x00")
|
||||
try:
|
||||
rel = p.resolve().relative_to(Path(root).resolve())
|
||||
h.update(rel.as_posix().lower().encode())
|
||||
except ValueError:
|
||||
h.update(p.resolve().as_posix().lower().encode())
|
||||
h.update(salt.encode())
|
||||
digest = h.hexdigest()
|
||||
|
||||
if st is not None:
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry is not None
|
||||
if (isinstance(entry, dict)
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns):
|
||||
entry["hash"] = digest # preserve a co-located word_count
|
||||
hashes = entry.get("hashes")
|
||||
if not isinstance(hashes, dict):
|
||||
hashes = {}
|
||||
entry["hashes"] = hashes
|
||||
hashes[salt] = digest # preserve a co-located word_count / other salts
|
||||
entry.pop("hash", None) # retire the un-salted legacy digest
|
||||
else:
|
||||
_stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest}
|
||||
_stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns,
|
||||
"hashes": {salt: digest}}
|
||||
_stat_index_dirty = True
|
||||
|
||||
return digest
|
||||
|
||||
@@ -49,4 +49,53 @@ def test_word_count_augments_existing_hash_entry(tmp_path, monkeypatch):
|
||||
assert cache.file_hash(f, tmp_path) == h
|
||||
key = str(cache._normalize_path(f).resolve())
|
||||
entry = cache._stat_index[key]
|
||||
assert entry.get("hash") == h and entry.get("word_count") == 3
|
||||
# #1989: digests are now stored per salt under "hashes" (salt = path relative
|
||||
# to root == "m.py" here), co-located with the word_count.
|
||||
assert entry.get("hashes", {}).get("m.py") == h and entry.get("word_count") == 3
|
||||
|
||||
|
||||
def test_file_hash_is_order_independent_across_roots(tmp_path, monkeypatch):
|
||||
"""#1989: the stat-index memo must be keyed by the salt (path relative to
|
||||
root) that enters the digest, so the same (file, root) returns the same
|
||||
digest regardless of what root was hashed first."""
|
||||
import hashlib
|
||||
from graphify import cache
|
||||
monkeypatch.setattr(cache, "_stat_index", {})
|
||||
monkeypatch.setattr(cache, "_stat_index_root", None)
|
||||
|
||||
root_a = tmp_path / "a"; root_a.mkdir()
|
||||
f = root_a / "doc.txt"; f.write_text("hello world\n")
|
||||
root_b = tmp_path / "b"; root_b.mkdir() # f is NOT under root_b -> abs-path salt
|
||||
|
||||
content = f.read_bytes()
|
||||
exp_rel = hashlib.sha256(content + b"\x00" + b"doc.txt").hexdigest()
|
||||
exp_abs = hashlib.sha256(
|
||||
content + b"\x00" + str(cache._normalize_path(f).resolve()).replace("\\", "/").lower().encode()
|
||||
).hexdigest()
|
||||
|
||||
# rel-first order
|
||||
assert cache.file_hash(f, root_a) == exp_rel
|
||||
assert cache.file_hash(f, root_b) == exp_abs # not served the rel digest
|
||||
assert cache.file_hash(f, root_a) == exp_rel # still stable
|
||||
|
||||
# abs-first order, fresh index
|
||||
monkeypatch.setattr(cache, "_stat_index", {})
|
||||
monkeypatch.setattr(cache, "_stat_index_root", None)
|
||||
assert cache.file_hash(f, root_b) == exp_abs
|
||||
assert cache.file_hash(f, root_a) == exp_rel # not served the abs digest
|
||||
|
||||
|
||||
def test_file_hash_ignores_legacy_unsalted_entry(tmp_path, monkeypatch):
|
||||
"""A pre-#1989 entry carrying a bare "hash" (no salt) is never trusted."""
|
||||
import hashlib
|
||||
from graphify import cache
|
||||
monkeypatch.setattr(cache, "_stat_index", {})
|
||||
monkeypatch.setattr(cache, "_stat_index_root", None)
|
||||
f = tmp_path / "m.py"; f.write_text("x = 1\n")
|
||||
st = f.stat()
|
||||
key = str(cache._normalize_path(f).resolve())
|
||||
cache._stat_index[key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": "deadbeef"}
|
||||
exp = hashlib.sha256(f.read_bytes() + b"\x00" + b"m.py").hexdigest()
|
||||
assert cache.file_hash(f, tmp_path) == exp # recomputed, not "deadbeef"
|
||||
entry = cache._stat_index[key]
|
||||
assert "hash" not in entry and entry["hashes"]["m.py"] == exp
|
||||
|
||||
Reference in New Issue
Block a user