mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
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:
+64
-33
@@ -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
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user