fix(reflect): work-memory staleness false-positive on relative source paths

The overlay fingerprint resolved a node's source_file against
graph_path.parent (the graphify-out/ dir), but source_file is stored
relative to the PROJECT root — so graphify-out/auth.py never existed and
_is_stale flagged EVERY verdict "code changed since — re-verify" the
moment it was written. (The original staleness test used an absolute
source_file, which masked it.)

Fix: resolve the file by trying the likely roots in order (.graphify_root
marker, graphify-out's parent, graph.json's own dir, cwd) and use the
first that exists — the same search at write and read — and fingerprint
file CONTENT only (sha256 of bytes, no path mixed in) so the hash is
root-independent and a committed sidecar stays valid across checkouts.
Drops the brittle directory-name-based root guess.

Adds a regression test with a relative source_file under the graphify-out
layout (stale=False right after reflect, True after an edit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-30 12:40:20 +01:00
parent 5779767fd3
commit 00e00a0b5f
2 changed files with 90 additions and 33 deletions
+64 -33
View File
@@ -664,8 +664,57 @@ def _resolve_canonical_id(cited: str, id_set: dict[str, str],
return None return None
def _code_fingerprint(node: dict[str, Any] | None, root: Path) -> str: def _resolve_source_path(src: str, graph_path: Path) -> Path | None:
"""File-level content hash of the node's ``source_file``, or '' if unavailable. """Locate a node's ``source_file`` on disk, returning an existing file or None.
``source_file`` is stored relative to the PROJECT root, but graph.json may
live in ``<root>/graphify-out/`` (so its own dir is not the root) or directly
at the root (``extract --out .``). Rather than guess the root from a directory
name (brittle: a ``GRAPHIFY_OUT`` override changes it), try the likely roots in
order and return the first where the file actually exists. The same candidate
search runs at write and read time, so the writer and reader resolve to the
same file. Order: the committed ``.graphify_root`` marker (#686), then the
graphify-out-parent, then graph.json's own dir, then the cwd.
"""
if not src:
return None
p = Path(src)
if p.is_absolute():
return p if p.is_file() else None
gp = Path(graph_path)
candidates: list[Path] = []
marker = gp.parent / ".graphify_root"
try:
if marker.is_file():
candidates.append(Path(marker.read_text(encoding="utf-8").strip()))
except OSError:
pass
candidates += [gp.parent.parent, gp.parent, Path(".")]
seen: set[str] = set()
for base in candidates:
key = str(base)
if key in seen:
continue
seen.add(key)
cand = base / p
if cand.is_file():
return cand
return None
def _content_hash(path: Path) -> str:
"""SHA256 of file CONTENT only (no path mixed in), so the fingerprint is
independent of which root resolved the file — write and read agree, and a
committed sidecar stays valid across machines/checkouts."""
import hashlib
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return ""
def _code_fingerprint(node: dict[str, Any] | None, graph_path: Path) -> str:
"""Content hash of the node's ``source_file``, or '' if unavailable.
Coarse on purpose — a file-level hash over-flags (any edit to the file marks Coarse on purpose — a file-level hash over-flags (any edit to the file marks
every node in it stale) rather than under-flags, which is the safe direction every node in it stale) rather than under-flags, which is the safe direction
@@ -673,17 +722,8 @@ def _code_fingerprint(node: dict[str, Any] | None, root: Path) -> str:
""" """
if not node: if not node:
return "" return ""
src = node.get("source_file") sp = _resolve_source_path(node.get("source_file") or "", graph_path)
if not src: return _content_hash(sp) if sp is not None else ""
return ""
try:
from graphify.cache import file_hash
p = Path(src)
if not p.is_absolute():
p = (Path(root) / p)
return file_hash(p, root)
except Exception:
return ""
def _provenance_for(node: str, prov_map: dict[str, list], def _provenance_for(node: str, prov_map: dict[str, list],
@@ -718,7 +758,6 @@ def build_learning_overlay(agg: dict[str, Any], graph_path: Path,
now = now.replace(tzinfo=timezone.utc) now = now.replace(tzinfo=timezone.utc)
graph_path = Path(graph_path) graph_path = Path(graph_path)
root = graph_path.parent
id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path) id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path)
prov_map = agg.get("_node_provenance", {}) prov_map = agg.get("_node_provenance", {})
@@ -742,7 +781,7 @@ def build_learning_overlay(agg: dict[str, Any], graph_path: Path,
"last": entry_src.get("last", ""), "last": entry_src.get("last", ""),
"label": str(node.get("label", cited)) if node else str(cited), "label": str(node.get("label", cited)) if node else str(cited),
"source_file": str(node.get("source_file") or "") if node else "", "source_file": str(node.get("source_file") or "") if node else "",
"code_fingerprint": _code_fingerprint(node, root), "code_fingerprint": _code_fingerprint(node, graph_path),
"provenance": _provenance_for(cited, prov_map, status), "provenance": _provenance_for(cited, prov_map, status),
} }
if status == "contested": if status == "contested":
@@ -804,36 +843,28 @@ def load_learning_overlay(graph_path: Path) -> dict[str, dict[str, Any]]:
nodes = data.get("nodes") nodes = data.get("nodes")
if not isinstance(nodes, dict): if not isinstance(nodes, dict):
return {} return {}
root = Path(graph_path).parent
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
for nid, entry in nodes.items(): for nid, entry in nodes.items():
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
merged = dict(entry) merged = dict(entry)
merged["stale"] = _is_stale(entry, root) merged["stale"] = _is_stale(entry, graph_path)
out[str(nid)] = merged out[str(nid)] = merged
return out return out
def _is_stale(entry: dict[str, Any], root: Path) -> bool: def _is_stale(entry: dict[str, Any], graph_path: Path) -> bool:
"""True if the node's source file changed (or vanished) since the fingerprint """True if the node's source file changed (or vanished) since the fingerprint
was taken.""" was taken. Uses the same file resolution + content hash as the writer, so a
stored = entry.get("code_fingerprint", "") freshly-written verdict on unchanged code is never spuriously stale."""
src = entry.get("source_file", "") src = entry.get("source_file", "")
if not src: if not src:
# No file to track. Stale only if a fingerprint was stored yet there's # No file to track — nothing to re-verify.
# nothing to compare against — treat as not stale (nothing to re-verify).
return False return False
p = Path(src) sp = _resolve_source_path(src, graph_path)
if not p.is_absolute(): if sp is None:
p = root / p return True # file gone / unfindable — re-verify
if not p.exists(): stored = entry.get("code_fingerprint", "")
return True # file gone — definitely re-verify
try:
from graphify.cache import file_hash
current = file_hash(p, root)
except Exception:
return bool(stored) # couldn't recompute; flag iff we had something to compare
if not stored: if not stored:
return True # had a file but never fingerprinted it -> can't trust -> stale return True # had a file but never fingerprinted it -> can't trust -> stale
return current != stored return _content_hash(sp) != stored
+26
View File
@@ -837,6 +837,32 @@ def test_loader_marks_entry_stale_when_source_file_changes(tmp_path):
assert after["auth_login"]["stale"] is True assert after["auth_login"]["stale"] is True
def test_relative_source_file_not_spuriously_stale_in_graphify_out_layout(tmp_path):
"""Regression: with a RELATIVE source_file and graph.json under graphify-out/,
a freshly-written verdict must NOT be flagged stale. The fingerprint resolves
the file relative to the PROJECT root (tmp_path), not graph.json's own dir
(graphify-out/) otherwise every node looked unfindable and was marked stale.
The edit case must still flip stale=True."""
out = tmp_path / "graphify-out" # graph.json lives here
(tmp_path / "auth.py").write_text("def login(): pass\n", encoding="utf-8")
_overlay_graph(out, [
# source_file is RELATIVE to the project root (tmp_path), as `extract` writes it
{"id": "auth_login", "label": "login()", "source_file": "auth.py", "community": 0},
])
mem = out / "memory"
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"])
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"])
reflect(mem, out / "reflections" / "LESSONS.md",
graph_path=out / "graph.json", now=_NOW)
fresh = load_learning_overlay(out / "graph.json")
assert fresh["auth_login"]["status"] == "preferred"
assert fresh["auth_login"]["stale"] is False # the bug: was spuriously True
(tmp_path / "auth.py").write_text("def login(): return 1 # changed\n", encoding="utf-8")
assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is True
def test_provenance_capped_to_five_most_recent(tmp_path): def test_provenance_capped_to_five_most_recent(tmp_path):
"""A node cited by >5 useful results keeps exactly the 5 most-recent in """A node cited by >5 useful results keeps exactly the 5 most-recent in
provenance (recent-first).""" provenance (recent-first)."""