From 1c161995bff2a5d6204b7ed635239a25c6fc1db0 Mon Sep 17 00:00:00 2001 From: harshaygadekar Date: Mon, 5 Oct 2026 21:45:49 +0530 Subject: [PATCH] fix(detect): scope incremental manifest anchoring to prevent false deletions (#3785) --- graphify/detect.py | 62 ++++++++++++++++++++++++++++++++++---------- tests/test_detect.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 52927f9f9c..59ae39b227 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -22,7 +22,7 @@ convert_google_workspace_file, google_workspace_enabled, ) -from graphify.paths import GRAPHIFY_OUT, out_path +from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path class FileType(str, Enum): @@ -2397,6 +2397,27 @@ def _collapse_manifest_duplicates( return result +def _manifest_storage_anchor(manifest_path: str | Path | None, root: Path | None) -> Path | None: + """When a manifest lives under /graphify-out/manifest.json and root + is a subfolder of , stored relative keys are anchored to (#3785).""" + if root is None or not manifest_path: + return root + try: + mp = Path(manifest_path).resolve() + if mp.parent.name in ("graphify-out", GRAPHIFY_OUT_NAME): + manifest_base = mp.parent.parent + root_resolved = Path(root).resolve() + if root_resolved != manifest_base: + try: + root_resolved.relative_to(manifest_base) + return manifest_base + except ValueError: + pass + except (OSError, RuntimeError): + pass + return root + + def load_manifest( manifest_path: str = _MANIFEST_PATH, *, @@ -2421,8 +2442,9 @@ def load_manifest( return raw if root is None: return _collapse_manifest_duplicates(raw.items(), _nfc) + anchor = _manifest_storage_anchor(manifest_path, root) or root return _collapse_manifest_duplicates( - raw.items(), lambda k: _nfc(_to_absolute_from_storage(k, root)) + raw.items(), lambda k: _nfc(_to_absolute_from_storage(k, anchor)) ) @@ -2624,18 +2646,10 @@ def _normalise_entry(entry): "semantic_hash": sem_h, } manifest[key] = entry - if root is not None: - # Persist in portable form: forward-slash relative paths. Keys outside - # ``root`` (out-of-tree symlinked corpora, --include sources) keep - # their absolute form so the manifest round-trips on the saving - # machine even when not every entry can be portably encoded. - # NFC after relativize so on-disk keys match what load_manifest - # re-anchors and compares against (#2221). A seeded absolute key and - # a seeded relative key for the same file collapse here too (#1964) - # -- _collapse_manifest_duplicates keeps whichever is more recently - # seen instead of whichever the dict comprehension iterates last. + storage_root = _manifest_storage_anchor(manifest_path, root) if root is not None else None + if storage_root is not None: manifest = _collapse_manifest_duplicates( - manifest.items(), lambda k: _nfc(_to_relative_for_storage(k, root)) + manifest.items(), lambda k: _nfc(_to_relative_for_storage(k, storage_root)) ) else: manifest = _collapse_manifest_duplicates(manifest.items(), _nfc) @@ -2813,10 +2827,32 @@ def detect_incremental( # current scan was EXCLUDED (ignore rules / --exclude changed) and must # not be reported as deleted. Mirrors the watch-side excluded-vs-deleted # distinction (#1795). + try: + root_res: Path | None = Path(root).resolve() if root is not None else None + except (OSError, RuntimeError): + root_res = Path(root) if root is not None else None + + def _in_root(path_str: str) -> bool: + if root_res is None: + return True + p = Path(path_str) + try: + p.relative_to(root_res) + return True + except ValueError: + pass + try: + p.resolve().relative_to(root_res) + return True + except (ValueError, OSError, RuntimeError): + return False + current_files = {_nfc(f) for flist in full["files"].values() for f in flist} deleted_files: list[str] = [] excluded_files: list[str] = [] for f in manifest: + if not _in_root(f): + continue if _nfc(f) in current_files: continue try: diff --git a/tests/test_detect.py b/tests/test_detect.py index 8c4aafe0b3..0994738f74 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -3652,6 +3652,67 @@ def test_detect_incremental_exclusion_stable_across_runs(tmp_path): assert inc2["excluded_files"] == [] +# ── #3785: Multi-directory manifest scoping and out-of-root deletion filter ── + +def test_detect_incremental_subfolder_ignores_out_of_root_manifest_entries(tmp_path): + """#3785: detect_incremental() on a subfolder must ignore manifest entries + belonging to other subdirectories or parent directories, neither reporting + them as deleted nor as excluded.""" + workspace = tmp_path / "workspace" + sub_a = workspace / "workflows" + sub_b = workspace / "experts" + sub_a.mkdir(parents=True) + sub_b.mkdir(parents=True) + + wf_file = sub_a / "workflow.py" + exp_file = sub_b / "expert.py" + wf_file.write_text("def run(): pass\n", encoding="utf-8") + exp_file.write_text("def consult(): pass\n", encoding="utf-8") + + manifest_path = str(workspace / "graphify-out" / "manifest.json") + + # Step 1: Workspace root or sibling subfolder is graphed + full = detect(workspace) + save_manifest(full["files"], manifest_path=manifest_path, root=workspace) + + # Step 2: Incremental update on subfolder A only + inc_a = detect_incremental(sub_a, manifest_path=manifest_path) + assert inc_a["deleted_files"] == [], f"Unexpected false deletions: {inc_a['deleted_files']}" + assert inc_a["excluded_files"] == [], f"Unexpected false exclusions: {inc_a['excluded_files']}" + + +def test_detect_incremental_cross_subfolder_shared_manifest(tmp_path): + """#3785: Sequential runs on distinct subfolders against a shared manifest + must preserve each other's entries so neither run clobbers the other or + reports false deletions.""" + workspace = tmp_path / "workspace" + sub_a = workspace / "workflows" + sub_b = workspace / "experts" + sub_a.mkdir(parents=True) + sub_b.mkdir(parents=True) + + (sub_a / "workflow.py").write_text("def run(): pass\n", encoding="utf-8") + (sub_b / "expert.py").write_text("def consult(): pass\n", encoding="utf-8") + + manifest_path = str(workspace / "graphify-out" / "manifest.json") + + # Run 1: Graph sub_b + full_b = detect(sub_b) + save_manifest(full_b["files"], manifest_path=manifest_path, root=sub_b) + + # Run 2: Incremental update on sub_a + inc_a = detect_incremental(sub_a, manifest_path=manifest_path) + assert inc_a["deleted_files"] == [] + assert inc_a["excluded_files"] == [] + save_manifest(inc_a["files"], manifest_path=manifest_path, root=sub_a) + + # Run 3: Incremental update on sub_b must still find its files unchanged + inc_b2 = detect_incremental(sub_b, manifest_path=manifest_path) + assert inc_b2["deleted_files"] == [] + assert inc_b2["excluded_files"] == [] + assert inc_b2["new_files"]["code"] == [] + + # ── #2838: manifest seen timestamps preserved for unchanged entries ── def test_save_manifest_unchanged_file_preserves_seen(tmp_path):