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
15 changes: 15 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6519,6 +6519,21 @@ def _sf_entry(sf: str, sf_path: Path) -> tuple[str, str, tuple[str, ...]]:
continue
sf_path = Path(sf)
if not sf_path.is_absolute():
# No id-remap needed here (an id is minted independently of this
# string), but the raw source_file itself can still carry a
# native `\` separator on Windows — every extractor sets it via
# plain str(path), and only the absolute-input branch below ever
# ran it through as_posix(). A relative-path caller (the
# documented `extract(paths)` entry point with no `root`, or
# `python -m graphify.extract <file>...`) got whichever
# separator convention that one extractor happened to use,
# diverging from the canonical POSIX form every other path
# (explicit `root`, the CLI, which always passes one) produces.
# That fragments string-equality lookups keyed on source_file
# (build._norm_source_file, analyze.find_import_cycles, the
# semantic-cache key) into two spellings of one file (#2625).
if "\\" in sf:
item["source_file"] = sf.replace("\\", "/")
continue
new_sf, canonical_id, keys = _sf_entry(str(sf), sf_path)
if "id" in item:
Expand Down
59 changes: 59 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3812,3 +3812,62 @@ def test_inferred_uses_edge_dropped_for_module_top_level_reference(tmp_path):
uses = _inferred_uses(result)

assert not any(tgt == "helpers_helper" for _, tgt in uses)


def test_relative_input_source_file_is_normalized_to_posix(tmp_path, monkeypatch):
"""#2625: extract()'s canonicalization pass only ran a relative source_file
through as_posix() when the caller passed an explicit `root` — the
documented `extract(paths)` entry point with no root (and
`python -m graphify.extract <file>...`) left it exactly as the extractor
set it. Every extractor sets source_file via plain str(path), which on
Windows renders with a native `\\` separator, diverging from the POSIX
form every other path (explicit root, the CLI) produces — fragmenting
the string-equality lookups keyed on source_file (build._norm_source_file,
analyze.find_import_cycles, the semantic-cache key) into two spellings of
one file.

A real WindowsPath can't be constructed on a POSIX test runner, so this
monkeypatches one extension's dispatch to return a node/edge whose
source_file is a literal backslash-separated string — exactly the shape
str(WindowsPath("sub/file.py")) produces on a real Windows machine —
and asserts extract()'s post-pass normalizes it, exercising the actual
fixed code path rather than the OS-dependent symptom.
"""
import graphify.extract as extract_mod

def _fake_windows_extractor(path):
return {
"nodes": [{
"id": "fake_node", "label": "fake", "file_type": "code",
"source_file": "sub\\file.py", "source_location": "L1",
}],
"edges": [{
"source": "fake_node", "target": "other_node", "relation": "calls",
"confidence": "EXTRACTED", "source_file": "sub\\file.py",
}],
}

real_get_extractor = extract_mod._get_extractor

def _patched_get_extractor(path):
if path.suffix == ".fakewin":
return _fake_windows_extractor
return real_get_extractor(path)

monkeypatch.setattr(extract_mod, "_get_extractor", _patched_get_extractor)

(tmp_path / "sub").mkdir()
target = tmp_path / "sub" / "file.fakewin"
target.write_text("irrelevant", encoding="utf-8")

old_cwd = Path.cwd()
try:
os.chdir(tmp_path)
result = extract([Path("sub/file.fakewin")])
finally:
os.chdir(old_cwd)

node = next(n for n in result["nodes"] if n["id"] == "fake_node")
edge = next(e for e in result["edges"] if e["source"] == "fake_node")
assert node["source_file"] == "sub/file.py", node["source_file"]
assert edge["source_file"] == "sub/file.py", edge["source_file"]