diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py index f823d64c3..4519de8f2 100644 --- a/graphify/extractors/csharp.py +++ b/graphify/extractors/csharp.py @@ -164,6 +164,35 @@ def _metadata(value: object) -> dict: return value if isinstance(value, dict) else {} +def _razor_imports_dir(source_file: object) -> str | None: + """The directory prefix an ``_Imports.razor``'s directives apply to. + + Returns the file's parent directory with normalized separators ("" for the + corpus root), or None when the file is not an ``_Imports.razor``. + """ + if not isinstance(source_file, str) or not source_file: + return None + norm = source_file.replace("\\", "/") + parts = norm.rstrip("/").rsplit("/", 1) + name = parts[-1] + if name.lower() != "_imports.razor": + return None + return parts[0] if len(parts) == 2 else "" + + +def _razor_dir_applies(imports_dir: str, source_file: str) -> bool: + """True when an ``_Imports.razor`` in ``imports_dir`` governs ``source_file``. + + The Razor compiler applies ``_Imports.razor`` directives to every Razor + file in the same directory and every subdirectory below it. + """ + norm = source_file.replace("\\", "/") + file_dir = norm.rsplit("/", 1)[0] if "/" in norm else "" + if not imports_dir: + return True # root _Imports.razor governs the whole tree + return file_dir == imports_dir or file_dir.startswith(imports_dir + "/") + + class CsharpNameResolver: """Namespace/using/alias-aware C# simple-name resolution. @@ -227,6 +256,26 @@ def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None: if entry not in bucket: bucket.append(entry) + # Blazor ``_Imports.razor`` (#3187): the Razor compiler applies its + # ``@using``/alias directives to every Razor file in the same directory + # and below, and the standard Blazor template keeps the app's + # namespaces there — so without this a bare ``@inject WidgetService`` + # in a page dangles even though the canonical definition is in the + # graph. Index each _Imports.razor's directives by the directory they + # govern; razor-family lookups fold them in. + self._razor_dir_usings: list[tuple[str, list[tuple[str, str, str | None]]]] = [] + self._razor_dir_aliases: list[ + tuple[str, dict[str, list[tuple[str, str, str | None]]]] + ] = [] + for sf, entries in self.namespace_usings_by_file.items(): + d = _razor_imports_dir(sf) + if d is not None: + self._razor_dir_usings.append((d, entries)) + for sf, alias_map in self.aliases_by_file.items(): + d = _razor_imports_dir(sf) + if d is not None: + self._razor_dir_aliases.append((d, alias_map)) + @staticmethod def _namespace(node: dict | None) -> str: metadata = _metadata((node or {}).get("metadata")) @@ -243,6 +292,46 @@ def _using_in_scope(self, scope_kind: str, scope_id: str | None, source_node: di return True return scope_id is not None and scope_id in self._scope_chain(source_node) + @staticmethod + def _is_razor_family(source_file: str) -> bool: + return isinstance(source_file, str) and source_file.lower().endswith( + (".razor", ".cshtml") + ) + + def _inherited_razor_usings( + self, source_file: str + ) -> list[tuple[str, str, str | None]]: + if not self._razor_dir_usings or not self._is_razor_family(source_file): + return [] + out: list[tuple[str, str, str | None]] = [] + for d, entries in self._razor_dir_usings: + if _razor_dir_applies(d, source_file): + for entry in entries: + if entry not in out: + out.append(entry) + return out + + def _aliases_for( + self, source_file: str + ) -> dict[str, list[tuple[str, str, str | None]]]: + own = self.aliases_by_file.get(source_file, {}) + if not self._razor_dir_aliases or not self._is_razor_family(source_file): + return own + inherited = [ + alias_map for d, alias_map in self._razor_dir_aliases + if _razor_dir_applies(d, source_file) + ] + if not inherited: + return own + merged = {alias: list(entries) for alias, entries in own.items()} + for alias_map in inherited: + for alias, entries in alias_map.items(): + bucket = merged.setdefault(alias, []) + for entry in entries: + if entry not in bucket: + bucket.append(entry) + return merged + def _scopes_for(self, source_node: dict, source_file: str) -> list[str]: def _append_unique(items: list[str], value: str) -> None: if value not in items: @@ -254,11 +343,14 @@ def _append_unique(items: list[str], value: str) -> None: for namespace, scope_kind, scope_id in self.namespace_usings_by_file.get(source_file, []): if self._using_in_scope(scope_kind, scope_id, source_node): _append_unique(scopes, namespace) + for namespace, scope_kind, scope_id in self._inherited_razor_usings(source_file): + if self._using_in_scope(scope_kind, scope_id, source_node): + _append_unique(scopes, namespace) return scopes def _resolve_alias(self, label: str, source_node: dict, source_file: str) -> str | None: hits = set() - for target_fqn, scope_kind, scope_id in self.aliases_by_file.get(source_file, {}).get(label, []): + for target_fqn, scope_kind, scope_id in self._aliases_for(source_file).get(label, []): if not self._using_in_scope(scope_kind, scope_id, source_node): continue base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn)) @@ -286,7 +378,7 @@ def resolve_type_name( * ``(None, False)`` — scoping knows nothing about the name; a caller may fall back (e.g. to the corpus-wide unique bare-name match). """ - if label in self.aliases_by_file.get(source_file, {}): + if label in self._aliases_for(source_file): return self._resolve_alias(label, source_node, source_file), True candidates: list[str] = [] for namespace in self._scopes_for(source_node, source_file): @@ -310,7 +402,7 @@ def resolve_qualified( if not isinstance(qualifier, str) or not qualifier: return None in_scope = [ - entry for entry in self.aliases_by_file.get(source_file, {}).get(qualifier, []) + entry for entry in self._aliases_for(source_file).get(qualifier, []) if self._using_in_scope(entry[1], entry[2], source_node) ] if in_scope: diff --git a/tests/test_razor_imports_usings.py b/tests/test_razor_imports_usings.py new file mode 100644 index 000000000..fb69fdf10 --- /dev/null +++ b/tests/test_razor_imports_usings.py @@ -0,0 +1,161 @@ +"""Blazor ``_Imports.razor`` global usings reach the C# resolver (#3187). + +#3188 routed a Razor ``@inject`` through the C# cross-file type resolver, but +only directives written in the page itself counted. The Razor compiler applies +``_Imports.razor`` ``@using``/alias directives to every Razor file in the same +directory and below — and the standard Blazor template keeps the app's +namespaces there, so a bare ``@inject WidgetService _w`` in a page dangled on +a sourceless stub even though the canonical definition was in the graph. +""" + +from graphify.extract import collect_files, extract + +SERVICE_CS = ( + "namespace Demo.Services;\n\n" + "public class WidgetService\n{\n" + ' public string GetName() => "widget";\n' + "}\n" +) + +ALPHA_RAZOR = ( + '@page "/alpha"\n' + "@inject WidgetService _widgets\n\n" + "
@_widgets.GetName()
\n" +) + + +def _extract(td, files): + for rel, body in files.items(): + p = td / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + return extract(collect_files(td), cache_root=td, parallel=False) + + +def _canonical_id(r): + return next( + n["id"] for n in r["nodes"] + if n.get("label") == "WidgetService" + and str(n.get("source_file", "")).endswith(".cs") + and (n.get("metadata") or {}).get("namespace") == "Demo.Services" + ) + + +def _razor_ref_targets(r, page_suffix=".razor"): + return { + e["target"] for e in r["edges"] + if e.get("relation") == "references" + and str(e.get("source_file", "")).endswith(page_suffix) + } + + +def test_same_directory_imports_razor_resolves_inject(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/AlphaPage.razor": ALPHA_RAZOR, + "Pages/_Imports.razor": "@using Demo.Services\n", + }) + assert _canonical_id(r) in _razor_ref_targets(r) + stubs = [n for n in r["nodes"] + if n.get("label") == "WidgetService" and not n.get("source_file")] + assert stubs == [], f"sourceless stub survived: {stubs}" + + +def test_root_imports_razor_governs_nested_pages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/Admin/AlphaPage.razor": ALPHA_RAZOR, + "_Imports.razor": "@using Demo.Services\n", + }) + assert _canonical_id(r) in _razor_ref_targets(r) + + +def test_sibling_directory_imports_razor_does_not_apply(tmp_path, monkeypatch): + """An _Imports.razor in an unrelated sibling directory must not leak in.""" + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/AlphaPage.razor": ALPHA_RAZOR, + "Components/_Imports.razor": "@using Demo.Services\n", + }) + assert _canonical_id(r) not in _razor_ref_targets(r), ( + "a sibling directory's _Imports.razor must not govern this page" + ) + + +def test_cs_files_do_not_inherit_imports_razor(tmp_path, monkeypatch): + """The Razor compiler's rule is Razor-only: a .cs file in the same + directory must not gain the _Imports.razor usings.""" + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/_Imports.razor": "@using Demo.Services\n", + "Pages/Helper.cs": ( + "namespace Demo.Pages;\n\n" + "public class Helper\n{\n" + " private readonly WidgetService _w;\n" + " public Helper(WidgetService w) => _w = w;\n" + "}\n" + ), + }) + cs_targets = { + e["target"] for e in r["edges"] + if e.get("relation") == "references" + and str(e.get("source_file", "")).endswith("Helper.cs") + } + assert _canonical_id(r) not in cs_targets, ( + "a .cs file must not inherit _Imports.razor usings" + ) + + +def test_ambiguous_inherited_using_still_dangles(tmp_path, monkeypatch): + """Two same-named types both brought in scope via _Imports.razor usings: + no arbitrary winner.""" + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "A/WidgetService.cs": ( + "namespace Demo.A;\n\npublic class WidgetService { }\n" + ), + "B/WidgetService.cs": ( + "namespace Demo.B;\n\npublic class WidgetService { }\n" + ), + "Pages/AlphaPage.razor": ALPHA_RAZOR, + "Pages/_Imports.razor": "@using Demo.A\n@using Demo.B\n", + }) + razor_targets = _razor_ref_targets(r) + defined = { + n["id"] for n in r["nodes"] + if n.get("label") == "WidgetService" and n.get("source_file") + } + assert not (razor_targets & defined), ( + "ambiguous inherited using must not pick a winner" + ) + + +def test_no_using_anywhere_still_dangles(tmp_path, monkeypatch): + """Control for the intentional behavior: with no using in scope the inject + dangles, matching a bare C# cross-namespace reference (that Razor file + would not compile either).""" + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/AlphaPage.razor": ALPHA_RAZOR, + }) + assert _canonical_id(r) not in _razor_ref_targets(r) + + +def test_imports_razor_alias_resolves(tmp_path, monkeypatch): + """An alias directive in _Imports.razor works like a page-local one.""" + monkeypatch.chdir(tmp_path) + r = _extract(tmp_path, { + "Services/WidgetService.cs": SERVICE_CS, + "Pages/AlphaPage.razor": ( + '@page "/alpha"\n' + "@inject Widgets _widgets\n\n" + "@_widgets.GetName()
\n" + ), + "Pages/_Imports.razor": "@using Widgets = Demo.Services.WidgetService\n", + }) + assert _canonical_id(r) in _razor_ref_targets(r)