diff --git a/graphify/extract.py b/graphify/extract.py index 03db9945..bfdaba7c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -10,7 +10,7 @@ import sys import textwrap from collections import Counter from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, Callable from .cache import load_cached, save_cached @@ -6574,6 +6574,31 @@ def extract( for e in all_edges: e["_origin"] = "ast" + # Canonicalize source_file to POSIX on every node AND edge (#2625). + # + # Extractors build source_file from the Path they were handed, so a run + # given RELATIVE inputs keeps the native separator on Windows. Only the + # relativizing branch of _sf_entry above ever calls as_posix(), so a single + # extraction could emit `src\lib\content.ts` and `src/pages/index.astro` + # side by side. source_file is compared as a STRING downstream + # (build._norm_source_file keying, _derive_prune_root, dedup, and + # analyze.find_import_cycles, which matches an edge's source_file against a + # node's by equality), so two spellings are two different files — the + # fragmentation of #683, and the reason the CLI path (which passes an + # explicit root) looked correct while the library entry point did not. + # + # Safe as a final pass: ids are minted through make_id, which collapses + # every non-word character — `\` and `/` alike — to `_`, so canonicalizing + # the separator here cannot desync an id from its source_file. + # + # PurePath is the NATIVE flavour on purpose: on POSIX a backslash is a legal + # filename character and must be left alone, so this only rewrites paths on + # the platform where `\` is actually a separator. + for _item in (*all_nodes, *all_edges): + _sf = _item.get("source_file") + if _sf and "\\" in str(_sf): + _item["source_file"] = PurePath(_sf).as_posix() + return { "nodes": all_nodes, "edges": all_edges, diff --git a/tests/test_extract.py b/tests/test_extract.py index 693c1a25..8dc7a027 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3328,3 +3328,54 @@ def test_rewire_does_not_bind_supertype_stub_to_function(): "source_file": "store.py", "weight": 1.0}] _rewire_unique_stub_nodes(nodes, edges) assert edges[0]["target"] == "BookStore" # inherits stub not bound to function + + +def test_extract_emits_posix_source_file_for_relative_inputs(tmp_path): + r"""source_file must be canonical POSIX on every node AND edge, whatever + separator the caller's input paths used. + + Extractors build source_file from the Path handed to them, and only the + relativizing branch of extract()'s remap calls as_posix(), so a run given + relative inputs used to keep the native separator on Windows — mixing + `src\lib\content.ts` and `src/pages/index.astro` in one extraction. + source_file is compared as a string downstream (build keying, prune-root + derivation, dedup, analyze.find_import_cycles), so two spellings are two + different files (#683 / #2625). + + Uses the relative-input form deliberately: passing an explicit ``root`` + takes the branch that already normalized, and would make this vacuous. + """ + (tmp_path / "src" / "lib").mkdir(parents=True) + (tmp_path / "src" / "pages").mkdir(parents=True) + (tmp_path / "src" / "lib" / "content.ts").write_text( + "export function getPosts() { return []; }\n", encoding="utf-8" + ) + (tmp_path / "src" / "pages" / "index.astro").write_text( + "---\nimport { getPosts } from '../lib/content';\n" + "const posts = getPosts();\n---\n