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

{posts.length}

\n", + encoding="utf-8", + ) + + cwd = os.getcwd() + os.chdir(tmp_path) + try: + result = extract([Path("src/lib/content.ts"), Path("src/pages/index.astro")]) + finally: + os.chdir(cwd) + + carriers = [ + (kind, item.get("source_file")) + for kind, items in (("node", result["nodes"]), ("edge", result["edges"])) + for item in items + if item.get("source_file") + ] + assert carriers, "fixture produced nothing with a source_file; test would be vacuous" + + offenders = [(kind, sf) for kind, sf in carriers if "\\" in sf] + assert not offenders, f"native separator survived into source_file: {offenders}" + + # ...and both files are present under one spelling each, so the graph sees + # two files rather than four. + assert {sf for _, sf in carriers} == { + "src/lib/content.ts", "src/pages/index.astro", + } diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 49452a98..d62c4458 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -1407,8 +1407,11 @@ def test_alias_import_does_not_remap_an_owned_symbol_id(tmp_path, monkeypatch): } target_symbol = _make_id(_file_stem(target), "formatDate") mirror_symbol = _make_id(_file_stem(mirror), "formatDate") - assert symbols[str(target)] == target_symbol - assert symbols[str(mirror)] == mirror_symbol + # as_posix, not str: source_file is canonical POSIX in extract() output + # (#2625), while str(Path(...)) is the native spelling and so only matched + # on POSIX hosts. + assert symbols[target.as_posix()] == target_symbol + assert symbols[mirror.as_posix()] == mirror_symbol imports = [ edge @@ -1418,8 +1421,8 @@ def test_alias_import_does_not_remap_an_owned_symbol_id(tmp_path, monkeypatch): by_source: dict[str, list[str]] = {} for edge in imports: by_source.setdefault(edge["source_file"], []).append(edge["target"]) - assert by_source[str(button)] == [target_symbol] - assert by_source[str(mirror_user)] == [mirror_symbol] + assert by_source[button.as_posix()] == [target_symbol] + assert by_source[mirror_user.as_posix()] == [mirror_symbol] assert all(edge["source"] in node_ids and edge["target"] in node_ids for edge in imports)