diff --git a/graphify/extract.py b/graphify/extract.py index 39ebfd62e..a6c4fda25 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -44,6 +44,8 @@ from graphify.extractors.base import ( # noqa: F401 _read_source_bytes, _read_source_text, _read_text, + active_scan_root, + set_active_scan_root, ) from graphify.extractors.apex import extract_apex # noqa: F401 from graphify.extractors.bash import extract_bash # noqa: F401 @@ -8134,9 +8136,10 @@ def _xaml_project_root(path: Path) -> Path: break except OSError: continue - if _XAML_ACTIVE_EXTRACT_ROOT is None: + active_root = active_scan_root() + if active_root is None: return root - boundary = _XAML_ACTIVE_EXTRACT_ROOT.resolve() + boundary = active_root.resolve() try: root.resolve().relative_to(boundary) return root @@ -8147,7 +8150,7 @@ def _xaml_project_root(path: Path) -> Path: def _xaml_csharp_class_nodes(path: Path) -> dict[str, list[dict]]: from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore root = _xaml_project_root(path) - cache_key = str(root.resolve()) if _XAML_ACTIVE_EXTRACT_ROOT is not None else None + cache_key = str(root.resolve()) if active_scan_root() is not None else None if cache_key and cache_key in _XAML_CSHARP_CLASS_CACHE: return _XAML_CSHARP_CLASS_CACHE[cache_key] classes: dict[str, list[dict]] = {} @@ -8205,7 +8208,6 @@ def _xaml_pascal_name(name: str) -> str | None: _XAML_TOOLKIT_FIELD_RE = re.compile(r"\b(?P_?m?_?[A-Za-z_]\w*)\s*(?:=.*)?;") _XAML_TOOLKIT_METHOD_RE = re.compile(r"\b(?P[A-Za-z_]\w*)\s*\(") -_XAML_ACTIVE_EXTRACT_ROOT: Path | None = None _XAML_CSHARP_CLASS_CACHE: dict[str, dict[str, list[dict]]] = {} @@ -8839,13 +8841,15 @@ def _get_extractor(path: Path) -> Any | None: def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict: - global _XAML_ACTIVE_EXTRACT_ROOT - previous_root = _XAML_ACTIVE_EXTRACT_ROOT - _XAML_ACTIVE_EXTRACT_ROOT = root.resolve() + # Publish the in-flight scan root on extractors.base (the correct import + # direction) so root-dependent extractors — Markdown link resolution, the + # XAML C# project clamp — can read it without reaching back into this module + # (#3666). Despite the XAML-era name this wraps every extractor. + previous_root = set_active_scan_root(root.resolve()) try: return _safe_extract(extractor, path, scan_root=root) finally: - _XAML_ACTIVE_EXTRACT_ROOT = previous_root + set_active_scan_root(previous_root) def _worker_init(language_overrides: dict[str, str]) -> None: diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index 8c5f1d523..17d4ce600 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -6,6 +6,34 @@ from pathlib import Path from graphify.ids import make_id +# Ambient scan root of the extraction in flight. extract.py's +# _safe_extract_with_xaml_root sets it around every per-file extraction; the +# extractors that need the corpus boundary read it (Markdown link resolution and +# the XAML C# project-root clamp). It lives here rather than in graphify.extract +# so those extractors read it in the correct import direction (extract.py -> +# extractors/, never back), per MIGRATION.md invariant #4 — the old +# function-local `import graphify.extract` + getattr(..., None) was a package -> +# parent dependency that also failed silently if the module global were renamed +# (#3666). None outside extract(). +_ACTIVE_SCAN_ROOT: "Path | None" = None + + +def set_active_scan_root(root: "Path | None") -> "Path | None": + """Set the in-flight scan root, returning the previous value so the caller + can restore it — extraction nests (a XAML shard re-enters extract()).""" + global _ACTIVE_SCAN_ROOT + previous = _ACTIVE_SCAN_ROOT + _ACTIVE_SCAN_ROOT = root + return previous + + +def active_scan_root() -> "Path | None": + """The scan root of the extraction in flight, or None outside extract(). + + A direct ``extract_markdown()`` / ``extract_xaml()`` call (no surrounding + ``extract()``) sees None and the root-dependent behavior stays off.""" + return _ACTIVE_SCAN_ROOT + # Language built-in globals that AST may classify as call targets when used as # constructors or coercion functions (e.g. String(x), Number(x), Boolean(x)). # Without this filter they become god-nodes accumulating spurious edges from diff --git a/graphify/extractors/markdown.py b/graphify/extractors/markdown.py index 6c52f9d94..2a389be9f 100644 --- a/graphify/extractors/markdown.py +++ b/graphify/extractors/markdown.py @@ -139,15 +139,11 @@ def _parse_frontmatter_fallback(fm_lines: list[str]) -> dict: _MD_LINK_INDEX_CACHE: "dict[str, dict[str, list[tuple[int, str, Path]]]]" = {} -def _active_scan_root() -> "Path | None": - """The scan root of the extraction in flight, or None outside extract(). - - _safe_extract_with_xaml_root sets this for every extraction despite its - XAML-era name; a direct extract_markdown() call has no root and the - fallback stays off. - """ - import graphify.extract as _extract - return getattr(_extract, "_XAML_ACTIVE_EXTRACT_ROOT", None) +# The in-flight scan root now lives on extractors.base (correct import +# direction: extract.py -> extractors/, never back), so a rename can no longer +# silently disable link resolution the way the old getattr(graphify.extract, …, +# None) could (#3666). +from graphify.extractors.base import active_scan_root as _active_scan_root def _nfc(s: str) -> str: diff --git a/tests/test_extractor_scan_root_context.py b/tests/test_extractor_scan_root_context.py new file mode 100644 index 000000000..cb509fa16 --- /dev/null +++ b/tests/test_extractor_scan_root_context.py @@ -0,0 +1,73 @@ +"""The in-flight scan root is shared through extractors.base, not by reaching +back into graphify.extract — MIGRATION.md invariant #4 (#3666).""" +from __future__ import annotations + +import ast +from pathlib import Path + +from graphify.extractors import base as base_mod +from graphify.extractors import markdown as markdown_mod + + +def _imports_graphify_extract(module) -> bool: + """True if the module's source imports graphify.extract anywhere — a + top-level or a function-local `import graphify.extract` / `from + graphify.extract import ...`.""" + source = Path(module.__file__).read_text(encoding="utf-8") + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + if any(a.name == "graphify.extract" or a.name.startswith("graphify.extract.") + for a in node.names): + return True + elif isinstance(node, ast.ImportFrom): + if node.module == "graphify.extract" or (node.module or "").startswith("graphify.extract."): + return True + return False + + +def test_markdown_extractor_does_not_import_graphify_extract(): + """The Markdown extractor read the in-flight scan root via a function-local + `import graphify.extract` + getattr, a package->parent dependency that + violated invariant #4 and degraded silently on a rename (#3666). It must now + reach the shared context through extractors.base instead.""" + assert not _imports_graphify_extract(markdown_mod), ( + "extractors/markdown.py must not import graphify.extract — read the scan " + "root via extractors.base.active_scan_root() (MIGRATION.md invariant #4)" + ) + + +def test_active_scan_root_defaults_to_none(): + # A fresh process (no extraction in flight) has no active root. + assert base_mod.active_scan_root() is None + + +def test_active_scan_root_set_get_restore_nested(): + """set_active_scan_root returns the previous value so nested extraction can + restore it; active_scan_root reflects the current top of that stack.""" + assert base_mod.active_scan_root() is None + outer = Path("/repo").resolve() + inner = Path("/repo/vendored").resolve() + + prev_outer = base_mod.set_active_scan_root(outer) + try: + assert prev_outer is None + assert base_mod.active_scan_root() == outer + + prev_inner = base_mod.set_active_scan_root(inner) + try: + assert prev_inner == outer + assert base_mod.active_scan_root() == inner + finally: + base_mod.set_active_scan_root(prev_inner) + + assert base_mod.active_scan_root() == outer + finally: + base_mod.set_active_scan_root(prev_outer) + + assert base_mod.active_scan_root() is None + + +def test_markdown_active_scan_root_alias_is_base_accessor(): + """The Markdown extractor's _active_scan_root is the base accessor, so the + scan root extract() publishes is exactly what link resolution reads.""" + assert markdown_mod._active_scan_root is base_mod.active_scan_root