Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ def main() -> None:
print(" --labels PATH .graphify_labels.json (optional, auto-detected next to --graph)")
print(" --half-life-days N signal weight halves every N days (default 30)")
print(" --min-corroboration N distinct useful results to prefer a node (default 2)")
print(" check-update <path> check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
print(" check-update <path> report manifest drift and pending semantic updates (cron-safe)")
print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
Expand Down
25 changes: 24 additions & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1489,6 +1489,9 @@ def detect_incremental(
semantically.
kind="ast": a file is "changed" when its ast_hash is missing or its
content has changed. Use this for `graphify update`.
kind="graph": use ast_hash for code and semantic_hash for content files.
This reports whether the queryable graph is stale without treating an
AST-only code update as pending semantic work.
Fast path: mtime unchanged + hash matches → unchanged (free, no disk IO
beyond stat). Slow path: mtime bumped → compare MD5 against the relevant
Expand All @@ -1512,12 +1515,20 @@ def detect_incremental(
# No previous run - treat everything as new
full["incremental"] = True
full["new_files"] = full["files"]
full["added_files"] = {k: list(v) for k, v in full["files"].items()}
full["modified_files"] = {k: [] for k in full["files"]}
full["unchanged_files"] = {k: [] for k in full["files"]}
full["new_total"] = full["total_files"]
full["added_total"] = full["total_files"]
full["modified_total"] = 0
full["deleted_files"] = []
return full

new_files: dict[str, list[str]] = {k: [] for k in full["files"]}
added_files: dict[str, list[str]] = {k: [] for k in full["files"]}
modified_files: dict[str, list[str]] = {k: [] for k in full["files"]}
unchanged_files: dict[str, list[str]] = {k: [] for k in full["files"]}
semantic_types = {"document", "paper", "image", "video"}

for ftype, file_list in full["files"].items():
for f in file_list:
Expand All @@ -1534,7 +1545,11 @@ def detect_incremental(
# Normalise legacy {mtime, hash} to new schema
if "hash" in stored and "ast_hash" not in stored:
stored = {"mtime": stored.get("mtime", 0), "ast_hash": stored["hash"], "semantic_hash": ""}
hash_key = "semantic_hash" if kind == "semantic" else "ast_hash"
hash_key = (
"semantic_hash"
if kind == "semantic" or (kind == "graph" and ftype in semantic_types)
else "ast_hash"
)
stored_hash = stored.get(hash_key, "")
# Missing semantic_hash means update ran but extract hasn't — always re-extract
if not stored_hash:
Expand All @@ -1557,6 +1572,10 @@ def detect_incremental(

if changed:
new_files[ftype].append(f)
if stored is None:
added_files[ftype].append(f)
else:
modified_files[ftype].append(f)
else:
unchanged_files[ftype].append(f)

Expand All @@ -1567,7 +1586,11 @@ def detect_incremental(
new_total = sum(len(v) for v in new_files.values())
full["incremental"] = True
full["new_files"] = new_files
full["added_files"] = added_files
full["modified_files"] = modified_files
full["unchanged_files"] = unchanged_files
full["new_total"] = new_total
full["added_total"] = sum(len(v) for v in added_files.values())
full["modified_total"] = sum(len(v) for v in modified_files.values())
full["deleted_files"] = deleted_files
return full
43 changes: 37 additions & 6 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,17 +1138,48 @@ def _add_deleted_source(path: Path) -> None:


def check_update(watch_path: Path) -> bool:
"""Check for pending semantic update flag and notify the user if set.
"""Report corpus drift and any pending semantic-update flag.

Cron-safe: always returns True so cron jobs do not alarm.
Non-code file changes (docs, papers, images) require LLM-backed
re-extraction via `/graphify --update` — this function only signals
that the update is needed.
Uses the same discovery and ignore rules as extraction, comparing code
against its AST hash and content files against their semantic hash.
"""
flag = Path(watch_path) / _GRAPHIFY_OUT / "needs_update"
if flag.exists():
root = Path(watch_path).resolve()
out = Path(_GRAPHIFY_OUT)
if not out.is_absolute():
out = root / out
flag = out / "needs_update"
manifest = out / "manifest.json"

from graphify.detect import detect_incremental

incremental = detect_incremental(root, str(manifest), kind="graph")
added = int(incremental.get("added_total", 0))
modified = int(incremental.get("modified_total", 0))
deleted = len(incremental.get("deleted_files", []))
scan_errors = incremental.get("walk_errors", [])
has_drift = bool(added or modified or deleted)
has_flag = flag.exists()

if has_drift:
print(
f"[graphify check-update] Corpus drift in {root}: "
f"{added} added, {modified} changed, {deleted} removed."
)
if scan_errors:
print(
f"[graphify check-update] Scan incomplete: {len(scan_errors)} "
"directory scan error(s); freshness is unknown."
)
if has_flag:
print(f"[graphify check-update] Pending non-code changes in {watch_path}.")
if has_drift or has_flag:
print("[graphify check-update] Run `/graphify --update` to apply semantic re-extraction.")
elif not scan_errors:
print(
f"[graphify check-update] Up to date in {root}: "
"0 added, 0 changed, 0 removed."
)
return True


Expand Down
120 changes: 118 additions & 2 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ def test_watched_extensions_excludes_noise():

# --- watch() import error without watchdog ---

def test_check_update_no_flag_returns_true(tmp_path):
"""check_update returns True and is silent when needs_update flag is absent."""
def test_check_update_no_flag_returns_true(tmp_path, capsys):
"""check_update returns True and reports a verified clean corpus."""
from graphify.watch import check_update
assert check_update(tmp_path) is True
assert "Up to date" in capsys.readouterr().out


def test_check_update_with_flag_returns_true_and_prints(tmp_path, capsys):
Expand All @@ -86,6 +87,121 @@ def test_check_update_does_not_clear_flag(tmp_path):
assert flag.exists()


def test_check_update_reports_files_added_after_manifest(tmp_path, capsys):
"""#1765: files created after extraction must make the graph stale."""
from graphify.detect import save_manifest
from graphify.watch import check_update

original = tmp_path / "original.py"
original.write_text("value = 1\n")
manifest = tmp_path / "graphify-out" / "manifest.json"
save_manifest(
{"code": [str(original)]},
str(manifest),
kind="both",
root=tmp_path,
)
(tmp_path / "added.py").write_text("value = 2\n")

assert check_update(tmp_path) is True
out = capsys.readouterr().out
assert "1 added, 0 changed, 0 removed" in out
assert "/graphify --update" in out


def test_check_update_reports_changed_and_removed_files(tmp_path, capsys):
from graphify.detect import save_manifest
from graphify.watch import check_update

changed = tmp_path / "changed.py"
changed.write_text("value = 1\n")
removed = tmp_path / "removed.py"
removed.write_text("value = 2\n")
manifest = tmp_path / "graphify-out" / "manifest.json"
save_manifest(
{"code": [str(changed), str(removed)]},
str(manifest),
kind="both",
root=tmp_path,
)

prior_mtime = changed.stat().st_mtime
changed.write_text("value = 3\n")
os.utime(changed, (prior_mtime + 2, prior_mtime + 2))
removed.unlink()

check_update(tmp_path)
out = capsys.readouterr().out
assert "0 added, 1 changed, 1 removed" in out


def test_check_update_honors_ignore_rules_for_new_files(tmp_path, capsys):
from graphify.detect import save_manifest
from graphify.watch import check_update

(tmp_path / ".gitignore").write_text("ignored.py\n")
original = tmp_path / "original.py"
original.write_text("value = 1\n")
manifest = tmp_path / "graphify-out" / "manifest.json"
save_manifest(
{"code": [str(original)]},
str(manifest),
kind="both",
root=tmp_path,
)
(tmp_path / "ignored.py").write_text("secret = 1\n")

check_update(tmp_path)
out = capsys.readouterr().out
assert "Up to date" in out
assert "Corpus drift" not in out


def test_check_update_uses_ast_hash_for_code_only_manifest(tmp_path, capsys):
"""An AST-only code update is current even without a semantic hash."""
from graphify.detect import save_manifest
from graphify.watch import check_update

source = tmp_path / "main.py"
source.write_text("value = 1\n")
manifest = tmp_path / "graphify-out" / "manifest.json"
save_manifest(
{"code": [str(source)]},
str(manifest),
kind="ast",
root=tmp_path,
)

check_update(tmp_path)
assert "Up to date" in capsys.readouterr().out


def test_check_update_uses_semantic_hash_for_content(tmp_path, capsys):
"""An AST-only manifest write must not mark changed content as current."""
from graphify.detect import save_manifest
from graphify.watch import check_update

doc = tmp_path / "notes.md"
doc.write_text("# Original\n")
manifest = tmp_path / "graphify-out" / "manifest.json"
save_manifest(
{"document": [str(doc)]},
str(manifest),
kind="both",
root=tmp_path,
)
doc.write_text("# Changed\n")
save_manifest(
{"document": [str(doc)]},
str(manifest),
kind="ast",
root=tmp_path,
)

check_update(tmp_path)
assert "0 added, 1 changed, 0 removed" in capsys.readouterr().out


def test_watch_raises_without_watchdog(tmp_path, monkeypatch):
import builtins
real_import = builtins.__import__
Expand Down
Loading