diff --git a/graphify/cache.py b/graphify/cache.py index 4fd2074c..6e266c26 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/tests/test_word_count_cache.py b/tests/test_word_count_cache.py index 75bbb839..cfa5f5a3 100644 --- a/tests/test_word_count_cache.py +++ b/tests/test_word_count_cache.py @@ -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