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:
safishamsi
2026-07-18 12:03:34 +01:00
co-authored by Claude Opus 4.8
parent 689dd6ccfd
commit 995508c328
2 changed files with 82 additions and 13 deletions
+32 -12
View File
@@ -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
+50 -1
View File
@@ -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