From 449c7a469446a16e2411bbb603c5f1981f2f8f25 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 1 Aug 2026 10:45:51 -0300 Subject: [PATCH] fix: reconcile case-only source path aliases --- graphify/watch.py | 36 ++++++++++++++++++++++++++-- tests/test_watch.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index c87ec8e6f..dd7cd4224 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -414,10 +414,16 @@ def in_watch_root(self, source_file: str | None) -> bool: def is_evicted(self, item: dict, identities: set[str]) -> bool: return self.identity(item.get("source_file")) in identities - def rebase_preserved(self, item: dict) -> None: + def rebase_preserved( + self, + item: dict, + canonical_identities: dict[str, str] | None = None, + ) -> None: identity = self.identity(item.get("source_file")) if not identity: return + if canonical_identities: + identity = canonical_identities.get(identity, identity) identity_path = Path(identity) if not _is_relative_to(identity_path, self.watch_root): normalized = self.normalize(item.get("source_file")) @@ -495,6 +501,22 @@ def _reconcile_existing_graph( current_sources = { source_paths.absolute_identity(str(path), project_root) for path in code_files } + current_sources_by_casefold: dict[str, str] = {} + ambiguous_casefolds: set[str] = set() + for identity in current_sources: + if not identity: + continue + # APFS can resolve two case-only spellings to one file while Python + # keeps the spellings as distinct strings. Only accept the alias + # after samefile() proves both names point at the same disk object. + folded = identity.casefold() + previous = current_sources_by_casefold.get(folded) + if previous is not None and previous != identity: + ambiguous_casefolds.add(folded) + else: + current_sources_by_casefold[folded] = identity + for folded in ambiguous_casefolds: + current_sources_by_casefold.pop(folded, None) rebuilt_source_identities = { source_paths.absolute_identity(str(path), project_root) for path in extract_targets } @@ -520,6 +542,7 @@ def _reconcile_existing_graph( # excluded sources via the AST ownership rule below. excluded_alive_files: set[str] = set() excluded_alive_nodes = 0 + source_identity_aliases: dict[str, str] = {} _alive_cache: dict[str, bool] = {} for node in existing.get("nodes", []): source_file = node.get("source_file") @@ -551,6 +574,15 @@ def _reconcile_existing_graph( continue if identity not in current_sources: if identity: + canonical_identity = current_sources_by_casefold.get(identity.casefold()) + if canonical_identity and canonical_identity != identity: + try: + same_file = os.path.samefile(identity, canonical_identity) + except OSError: + same_file = False + if same_file: + source_identity_aliases[identity] = canonical_identity + continue alive = _alive_cache.get(identity) if alive is None: alive = Path(identity).exists() @@ -631,7 +663,7 @@ def _reconcile_existing_graph( preserved_hyperedges.append(edge) for item in preserved_nodes + preserved_edges + preserved_hyperedges: - source_paths.rebase_preserved(item) + source_paths.rebase_preserved(item, source_identity_aliases) return { "nodes": result["nodes"] + preserved_nodes, diff --git a/tests/test_watch.py b/tests/test_watch.py index 1b2efce33..95494736f 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -1781,6 +1781,64 @@ def test_rebuild_code_preserves_nodes_from_excluded_but_alive_file(tmp_path, cap assert "fail-closed: kept" in capsys.readouterr().out +def test_reconcile_case_alias_uses_current_source_spelling(tmp_path, monkeypatch, capsys): + """A stored path whose case-only spelling resolves to the scanned file is + current, not excluded. Preserve its semantic node without a fail-closed + warning and rewrite source_file to the scanner's canonical spelling. + + ``samefile`` is patched so this regression runs on case-sensitive CI while + modelling the APFS behavior that exposed the bug on macOS. + """ + from graphify import watch as watch_mod + from graphify.watch import _reconcile_existing_graph + + corpus = tmp_path / "corpus" + corpus.mkdir() + current = corpus / "Design.md" + current.write_text("# Design\n", encoding="utf-8") + out = corpus / "graphify-out" + out.mkdir() + graph_path = out / "graph.json" + graph_path.write_text( + json.dumps({ + "nodes": [{ + "id": "design_concept", + "label": "Design concept", + "file_type": "concept", + "source_file": "design.md", + }], + "links": [], + "hyperedges": [], + }), + encoding="utf-8", + ) + + real_samefile = watch_mod.os.path.samefile + + def case_insensitive_samefile(left, right): + if str(left).casefold() == str(right).casefold(): + return True + return real_samefile(left, right) + + monkeypatch.setattr(watch_mod.os.path, "samefile", case_insensitive_samefile) + + result, _ = _reconcile_existing_graph( + graph_path, + {"nodes": [], "edges": [], "hyperedges": []}, + out=out, + project_root=corpus, + watch_root=corpus, + code_files=[current], + extract_targets=[], + full_rebuild=False, + deleted_paths=set(), + deleted_source_identities=set(), + ) + + assert [node["source_file"] for node in result["nodes"]] == ["Design.md"] + assert "fail-closed: kept" not in capsys.readouterr().out + + def test_rebuild_code_still_evicts_when_excluded_file_is_also_deleted(tmp_path): """The fail-closed preserve must not weaken true-deletion eviction: once the excluded file is actually gone from disk, its nodes are evicted as before."""