fix(extract): canonicalize source_file to POSIX separators (#2627)

This commit is contained in:
rajashidattapy
2026-08-11 20:03:55 +01:00
committed by safishamsi
parent 33283f5356
commit ce942e144f
3 changed files with 84 additions and 5 deletions
+26 -1
View File
@@ -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,
+51
View File
@@ -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<h1>{posts.length}</h1>\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",
}
+7 -4
View File
@@ -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)