diff --git a/graphify/__main__.py b/graphify/__main__.py index 708c45819..e9ed1ce26 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 check needs_update flag and notify if semantic re-extraction is pending (cron-safe)") + print(" check-update 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)") diff --git a/graphify/detect.py b/graphify/detect.py index 84dd5e1b6..e82be8e1c 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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 @@ -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: @@ -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: @@ -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) @@ -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 diff --git a/graphify/watch.py b/graphify/watch.py index db5d2a86d..44af3b7bb 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 24e698169..3632ffac6 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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): @@ -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__