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
36 changes: 34 additions & 2 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
}
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down