diff --git a/desloppify/languages/_framework/frameworks/registry.py b/desloppify/languages/_framework/frameworks/registry.py index fe2bb5fb0..378195f27 100644 --- a/desloppify/languages/_framework/frameworks/registry.py +++ b/desloppify/languages/_framework/frameworks/registry.py @@ -8,6 +8,18 @@ FRAMEWORK_SPECS: dict[str, FrameworkSpec] = {} +# Per-ecosystem cache for ``framework_source_extensions``. Invalidated by any +# registry mutation (``register_framework_spec``). Keyed by the normalized +# ecosystem string ("" for the unfiltered query) so each call shape gets its +# own slot. In production the registry is immutable after startup, so this +# caches a single tuple per ecosystem for the process lifetime; in tests the +# fixture-driven mutations invalidate it cleanly. +_EXTENSIONS_CACHE: dict[str, tuple[str, ...]] = {} + + +def _invalidate_extensions_cache() -> None: + _EXTENSIONS_CACHE.clear() + def register_framework_spec(spec: FrameworkSpec) -> None: """Register a framework spec by id.""" @@ -15,6 +27,7 @@ def register_framework_spec(spec: FrameworkSpec) -> None: if not key: raise ValueError("FrameworkSpec.id must be non-empty") FRAMEWORK_SPECS[key] = spec + _invalidate_extensions_cache() def get_framework_spec(framework_id: str) -> FrameworkSpec | None: @@ -39,9 +52,15 @@ def _register_builtin_specs() -> None: """Register built-in framework specs shipped with the repo.""" if FRAMEWORK_SPECS: return + from .specs.astro import ASTRO_SPEC from .specs.nextjs import NEXTJS_SPEC + from .specs.svelte import SVELTE_SPEC + from .specs.vue import VUE_SPEC register_framework_spec(NEXTJS_SPEC) + register_framework_spec(ASTRO_SPEC) + register_framework_spec(SVELTE_SPEC) + register_framework_spec(VUE_SPEC) def ensure_builtin_specs_loaded() -> None: @@ -49,9 +68,40 @@ def ensure_builtin_specs_loaded() -> None: _register_builtin_specs() +def framework_source_extensions(ecosystem: str | None = None) -> tuple[str, ...]: + """Return the sorted, deduplicated source extensions across registered specs. + + Used by dep-graph builders to learn which non-host-language file types + (e.g. ``.astro``, ``.svelte``, ``.vue``) should be scanned as importers + of the host language's modules. Lookup is driven by the registry, so + adding a new framework with ``source_extensions`` automatically extends + every plugin that calls this — no infrastructure edits required. + + Cached per ecosystem key; ``register_framework_spec`` invalidates the + cache so tests that mutate the registry see fresh results. + """ + ensure_builtin_specs_loaded() + cache_key = (ecosystem or "").strip().lower() + cached = _EXTENSIONS_CACHE.get(cache_key) + if cached is not None: + return cached + result = tuple( + sorted( + { + ext + for spec in list_framework_specs(ecosystem=ecosystem).values() + for ext in spec.source_extensions + } + ) + ) + _EXTENSIONS_CACHE[cache_key] = result + return result + + __all__ = [ "FRAMEWORK_SPECS", "ensure_builtin_specs_loaded", + "framework_source_extensions", "get_framework_spec", "list_framework_specs", "register_framework_spec", diff --git a/desloppify/languages/_framework/frameworks/specs/astro.py b/desloppify/languages/_framework/frameworks/specs/astro.py new file mode 100644 index 000000000..da2583479 --- /dev/null +++ b/desloppify/languages/_framework/frameworks/specs/astro.py @@ -0,0 +1,25 @@ +"""Astro framework spec (Node ecosystem).""" + +from __future__ import annotations + +from ..types import DetectionConfig, FrameworkSpec + +ASTRO_SPEC = FrameworkSpec( + id="astro", + label="Astro", + ecosystem="node", + detection=DetectionConfig( + dependencies=("astro",), + config_files=( + "astro.config.mjs", + "astro.config.js", + "astro.config.ts", + "astro.config.cjs", + ), + script_pattern=r"(?:^|\s)astro(?:\s|$)", + ), + source_extensions=(".astro",), +) + + +__all__ = ["ASTRO_SPEC"] diff --git a/desloppify/languages/_framework/frameworks/specs/svelte.py b/desloppify/languages/_framework/frameworks/specs/svelte.py new file mode 100644 index 000000000..567503553 --- /dev/null +++ b/desloppify/languages/_framework/frameworks/specs/svelte.py @@ -0,0 +1,29 @@ +"""Svelte framework spec (Node ecosystem). + +Covers both Svelte 4 and SvelteKit. The detection config matches the +package name (``svelte``) and SvelteKit's metadata (``@sveltejs/kit``) +so projects using either show as present. +""" + +from __future__ import annotations + +from ..types import DetectionConfig, FrameworkSpec + +SVELTE_SPEC = FrameworkSpec( + id="svelte", + label="Svelte", + ecosystem="node", + detection=DetectionConfig( + dependencies=("svelte", "@sveltejs/kit"), + config_files=( + "svelte.config.js", + "svelte.config.mjs", + "svelte.config.ts", + ), + script_pattern=r"(?:^|\s)svelte-kit(?:\s|$)", + ), + source_extensions=(".svelte",), +) + + +__all__ = ["SVELTE_SPEC"] diff --git a/desloppify/languages/_framework/frameworks/specs/vue.py b/desloppify/languages/_framework/frameworks/specs/vue.py new file mode 100644 index 000000000..3c4002e2a --- /dev/null +++ b/desloppify/languages/_framework/frameworks/specs/vue.py @@ -0,0 +1,31 @@ +"""Vue framework spec (Node ecosystem). + +Covers Vue 3 (and Vue 2 leftovers) as well as Nuxt, which builds on +Vue's single-file component model and shares the ``.vue`` extension. +""" + +from __future__ import annotations + +from ..types import DetectionConfig, FrameworkSpec + +VUE_SPEC = FrameworkSpec( + id="vue", + label="Vue", + ecosystem="node", + detection=DetectionConfig( + dependencies=("vue", "nuxt"), + config_files=( + "vue.config.js", + "vue.config.mjs", + "vue.config.ts", + "nuxt.config.js", + "nuxt.config.mjs", + "nuxt.config.ts", + ), + script_pattern=r"(?:^|\s)(?:vue-cli-service|nuxt)(?:\s|$)", + ), + source_extensions=(".vue",), +) + + +__all__ = ["VUE_SPEC"] diff --git a/desloppify/languages/_framework/frameworks/types.py b/desloppify/languages/_framework/frameworks/types.py index 183ad9c91..feeca6362 100644 --- a/desloppify/languages/_framework/frameworks/types.py +++ b/desloppify/languages/_framework/frameworks/types.py @@ -65,6 +65,13 @@ class FrameworkSpec: excludes: tuple[str, ...] = () scanners: tuple[ScannerRule, ...] = () tools: tuple[ToolIntegration, ...] = () + # Non-host-language source files that should still be scanned as importers + # of the host language's modules (e.g. `.astro` files importing `.js`). + # The dep-graph builder reads these files via a regex pass and records + # importer edges into ``file_set``; framework files themselves are not + # added as graph nodes. Default empty for specs whose source files are + # already covered by the host language's extensions (e.g. Next.js). + source_extensions: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/desloppify/languages/_framework/generic_support/core.py b/desloppify/languages/_framework/generic_support/core.py index 90eb46409..3a53bd176 100644 --- a/desloppify/languages/_framework/generic_support/core.py +++ b/desloppify/languages/_framework/generic_support/core.py @@ -97,6 +97,7 @@ def generic_lang( file_finder, extract_fn, dep_graph_fn, has_treesitter, ts_spec = _resolve_generic_extractors( path_extensions=extensions, opts=opts, + frameworks=frameworks, ) phases = _build_generic_phases( tool_specs=tool_specs, diff --git a/desloppify/languages/_framework/generic_support/registration.py b/desloppify/languages/_framework/generic_support/registration.py index 0faa74ce6..03f433962 100644 --- a/desloppify/languages/_framework/generic_support/registration.py +++ b/desloppify/languages/_framework/generic_support/registration.py @@ -76,6 +76,7 @@ def _resolve_generic_extractors( *, path_extensions: list[str], opts: GenericLangOptions, + frameworks: bool = False, ) -> tuple[Any, Any, Any, bool, Any]: file_finder = make_file_finder(path_extensions, opts.exclude) extract_fn = noop_extract_functions @@ -90,13 +91,36 @@ def _resolve_generic_extractors( if not is_available(): return file_finder, extract_fn, dep_graph_fn, has_treesitter, ts_spec + from desloppify.languages._framework.frameworks.registry import ( + framework_source_extensions, + ) from desloppify.languages._framework.treesitter.analysis.extractors import make_ts_extractor from desloppify.languages._framework.treesitter.imports.graph import make_ts_dep_builder has_treesitter = True extract_fn = make_ts_extractor(ts_spec, file_finder) if ts_spec.import_query and ts_spec.resolve_import: - dep_graph_fn = make_ts_dep_builder(ts_spec, file_finder) + # Pull the framework source-extension list from the FrameworkSpec + # registry — adding a new framework (e.g. Qwik, Solid, Marko) is one + # spec file with ``source_extensions=(...)`` and zero edits here. + framework_extensions = ( + framework_source_extensions(ecosystem="node") if frameworks else None + ) + # Build a framework-scoped file finder that respects the same + # ``opts.exclude`` as the host language finder, so framework files in + # user-excluded directories (e.g. ``examples/``, ``e2e/``) don't + # contribute spurious importer edges. + framework_file_finder = ( + make_file_finder(list(framework_extensions), opts.exclude) + if framework_extensions + else None + ) + dep_graph_fn = make_ts_dep_builder( + ts_spec, + file_finder, + framework_extensions=framework_extensions, + framework_file_finder=framework_file_finder, + ) return file_finder, extract_fn, dep_graph_fn, has_treesitter, ts_spec diff --git a/desloppify/languages/_framework/treesitter/imports/graph.py b/desloppify/languages/_framework/treesitter/imports/graph.py index 361586356..45f035333 100644 --- a/desloppify/languages/_framework/treesitter/imports/graph.py +++ b/desloppify/languages/_framework/treesitter/imports/graph.py @@ -3,10 +3,15 @@ from __future__ import annotations import os +import re from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any +from desloppify.base.discovery.file_paths import resolve_path +from desloppify.base.discovery.source import find_source_files +from desloppify.base.search.grep import grep_files + from .cache import get_or_parse_tree from ..analysis.extractors import _get_parser, _make_query, _run_query, _unwrap_node @@ -14,15 +19,58 @@ from desloppify.languages._framework.treesitter import TreeSitterLangSpec +_FRAMEWORK_IMPORT_RE = re.compile( + r"""(?:from\s+|import\s+)(?:type\s+)?['"]([^'"]+)['"]""" +) +"""Import-specifier extractor for framework files (.astro/.svelte/.vue). + +These files mix JS/TS imports with framework-specific syntax that the host +language's tree-sitter grammar can't parse cleanly, so we fall back to a +regex over the raw source. Matches both ``import x from 'y'`` and +``import 'y'`` (with optional ``type`` qualifier).""" + + +_INLINE_COMMENT_RE = re.compile(r"//[^\n]*|/\*.*?\*/|") + + +def _strip_inline_comments(line: str) -> str: + """Remove single-line JS/HTML comment regions from a source line. + + Applied before regex import extraction so commented-out imports + (``// import './foo'``, ``/* import 'x' */``, ````) + don't produce false-positive importer edges and mask genuine orphans. + Multi-line block comments aren't detected — grep_files yields one row + per matched line, so mid-block context isn't reconstructable here. + """ + return _INLINE_COMMENT_RE.sub("", line) + + def ts_build_dep_graph( path: Path, spec: TreeSitterLangSpec, file_list: list[str], + *, + framework_extensions: tuple[str, ...] | None = None, + framework_file_finder: Callable[[Path], list[str]] | None = None, ) -> dict[str, dict[str, Any]]: """Build a dependency graph by parsing imports with tree-sitter. Returns the same shape as Python/TS dep graphs: {file: {"imports": set[str], "importers": set[str], "import_count": int, "importer_count": int}} + + When ``framework_extensions`` is provided (e.g. ``(".astro", ".svelte", + ".vue")``), files under ``path`` with those extensions are also scanned + via a regex-based import extractor, and their imports are recorded as + importer edges on matching entries in ``file_list``. The framework files + themselves are intentionally not added as graph nodes — they don't belong + to the host language's extension set, so we never want them to surface in + orphan or coupling reports. + + When ``framework_file_finder`` is provided, it's used to enumerate + framework files (so callers can supply an exclude-aware finder built via + ``make_file_finder``). When omitted, a plain ``find_source_files`` scan + is used — convenient for unit tests but doesn't honor user-configured + exclusions; production callers should pass a finder. """ if not spec.import_query or not spec.resolve_import: return {} @@ -31,14 +79,21 @@ def ts_build_dep_graph( query = _make_query(language, spec.import_query) scan_path = str(path.resolve()) - file_set = set(file_list) + # Absolutize input paths so the graph keys, tree-sitter file reads, and + # import-resolver outputs all use the same coordinate system. Callers may + # pass either absolute paths (explicit unit-test lists) or paths relative + # to PROJECT_ROOT (production `find_source_files` output) — both worked + # historically only when CWD happened to equal PROJECT_ROOT, which masked + # missing edges in environments where CWD is anywhere else. + absolute_file_list = [resolve_path(f) for f in file_list] + file_set = set(absolute_file_list) graph: dict[str, dict[str, Any]] = {} # Initialize all files in the graph. - for f in file_list: + for f in absolute_file_list: graph[f] = {"imports": set(), "importers": set()} - for filepath in file_list: + for filepath in absolute_file_list: cached = get_or_parse_tree(filepath, parser, spec.grammar) if cached is None: continue @@ -79,13 +134,24 @@ def ts_build_dep_graph( if not os.path.isabs(resolved): resolved = os.path.normpath(os.path.join(scan_path, resolved)) - # Only track edges within the scanned file set. + # All paths in file_set are absolute (absolutized at function entry) + # and `resolved` is absolutized above, so direct membership suffices. if resolved not in file_set: continue graph[filepath]["imports"].add(resolved) - if resolved in graph: - graph[resolved]["importers"].add(filepath) + graph[resolved]["importers"].add(filepath) + + if framework_extensions: + _add_framework_importers( + graph=graph, + file_set=file_set, + framework_extensions=framework_extensions, + framework_file_finder=framework_file_finder, + spec=spec, + scan_path=scan_path, + path=path, + ) # Finalize: add counts. for data in graph.values(): @@ -95,19 +161,82 @@ def ts_build_dep_graph( return graph +def _add_framework_importers( + *, + graph: dict[str, dict[str, Any]], + file_set: set[str], + framework_extensions: tuple[str, ...], + framework_file_finder: Callable[[Path], list[str]] | None, + spec: TreeSitterLangSpec, + scan_path: str, + path: Path, +) -> None: + """Add framework files (.astro/.svelte/.vue) as importer edges on graph nodes. + + Framework files carry their imports in fenced or top-level sections that + the host language's tree-sitter grammar can't parse cleanly. We extract + import specifiers with a regex and resolve them via the spec's own + ``resolve_import`` so framework-file imports behave exactly like + host-language imports — only edges into ``file_set`` are kept, and the + framework files themselves are not added as graph nodes. + """ + if framework_file_finder is not None: + fw_files = framework_file_finder(path) + else: + fw_files = find_source_files(path, list(framework_extensions)) + if not fw_files: + return + + # grep_files yields one row per matched line, so resolving each filepath + # to absolute inside the loop would re-do the work N times per file. + # Build the abs-path map once up front. + fw_abs = {f: resolve_path(f) for f in fw_files} + + for filepath, _lineno, line in grep_files( + r"""(?:\bfrom\s+['"]|\bimport\s+['"])""", fw_files + ): + importer_abs = fw_abs[filepath] + cleaned_line = _strip_inline_comments(line) + for match in _FRAMEWORK_IMPORT_RE.finditer(cleaned_line): + import_text = match.group(1) + resolved = spec.resolve_import(import_text, importer_abs, scan_path) + if resolved is None: + continue + if not os.path.isabs(resolved): + resolved = os.path.normpath(os.path.join(scan_path, resolved)) + if resolved not in file_set: + continue + graph[resolved]["importers"].add(importer_abs) + + def make_ts_dep_builder( spec: TreeSitterLangSpec, file_finder: Callable[[Path], list[str]], + *, + framework_extensions: tuple[str, ...] | None = None, + framework_file_finder: Callable[[Path], list[str]] | None = None, ) -> Callable[[Path], dict[str, dict[str, Any]]]: """Create a dep graph builder bound to a TreeSitterLangSpec + file finder. Returns a callable with signature (path: Path) -> dict, matching the contract expected by LangConfig.build_dep_graph. + + When ``framework_extensions`` is provided, framework files under the + scanned path also contribute importer edges (see ``ts_build_dep_graph``). + ``framework_file_finder`` lets callers thread the same exclude set used + for the host language; when omitted, the framework scan honors only + default exclusions. """ def build(path: Path) -> dict[str, dict[str, Any]]: file_list = file_finder(path) - return ts_build_dep_graph(path, spec, file_list) + return ts_build_dep_graph( + path, + spec, + file_list, + framework_extensions=framework_extensions, + framework_file_finder=framework_file_finder, + ) return build diff --git a/desloppify/languages/javascript/tests/test_init.py b/desloppify/languages/javascript/tests/test_init.py index c36013c1a..3936de184 100644 --- a/desloppify/languages/javascript/tests/test_init.py +++ b/desloppify/languages/javascript/tests/test_init.py @@ -16,6 +16,11 @@ from desloppify.languages import get_lang from desloppify.languages._framework.generic_parts.parsers import parse_eslint +from desloppify.languages._framework.treesitter import is_available as ts_available + +requires_treesitter = pytest.mark.skipif( + not ts_available(), reason="tree-sitter-language-pack not installed" +) @pytest.fixture(scope="module") @@ -85,6 +90,73 @@ def test_fix_cmd_registered(cfg): assert cfg.fixers, "expected at least one fixer (fix_cmd) to be registered for JavaScript" +@requires_treesitter +def test_dep_graph_treats_astro_frontmatter_as_importer(cfg, tmp_path, set_project_root): + """JS modules imported only from .astro frontmatter must not be orphans. + + Regression for a false-positive class on Astro projects: the page/component + `.astro` files weren't in the JS plugin's enumerated extensions, so any + .js module they imported showed `importer_count == 0` and tripped the + orphaned-file detector. With ``frameworks=True`` on the plugin and the + framework-extensions wiring in the shared graph builder, the importer + edge is now recorded. + """ + del set_project_root # PROJECT_ROOT scoped to tmp_path + + src = tmp_path / "src" + src.mkdir() + config = src / "config.js" + config.write_text("export const LIST_UUID = 'xyz';\n", encoding="utf-8") + + pages = src / "pages" + pages.mkdir() + (pages / "index.astro").write_text( + "---\nimport { LIST_UUID } from '../config.js';\n---\n
\n", + encoding="utf-8", + ) + + graph = cfg.build_dep_graph(tmp_path) + config_key = str(config.resolve()) + astro_key = str((pages / "index.astro").resolve()) + + assert config_key in graph, ( + f"config.js missing from graph; keys: {sorted(graph)[:5]}…" + ) + assert graph[config_key]["importer_count"] >= 1 + assert astro_key in graph[config_key]["importers"] + # The .astro file itself is not a graph node — it must not appear as + # an orphan in JS plugin reports. + assert astro_key not in graph + + +@requires_treesitter +def test_dep_graph_treats_mjs_config_as_importer(cfg, tmp_path, set_project_root): + """.mjs config files (e.g. astro.config.mjs, vite.config.mjs) must register + as importers of the .js modules they pull in. + + .mjs IS in the JS plugin's extension list, so this should "just work" — + but covering it explicitly guards against regressions in how the + tree-sitter pass handles ESM-only files. + """ + del set_project_root + + src = tmp_path / "src" + src.mkdir() + helper = src / "helper.js" + helper.write_text("export const ok = true;\n", encoding="utf-8") + (tmp_path / "tool.config.mjs").write_text( + "import { ok } from './src/helper.js';\nexport default { ok };\n", + encoding="utf-8", + ) + + graph = cfg.build_dep_graph(tmp_path) + helper_key = str(helper.resolve()) + mjs_key = str((tmp_path / "tool.config.mjs").resolve()) + + assert helper_key in graph + assert mjs_key in graph[helper_key]["importers"] + + def test_parsing_eslint_format(): """Verify that ESLint JSON output is parsed correctly. diff --git a/desloppify/languages/typescript/detectors/deps/__init__.py b/desloppify/languages/typescript/detectors/deps/__init__.py index 993ea27ee..7367598a2 100644 --- a/desloppify/languages/typescript/detectors/deps/__init__.py +++ b/desloppify/languages/typescript/detectors/deps/__init__.py @@ -36,8 +36,20 @@ from desloppify.languages.typescript.detectors.deps.runtime import ( ts_alias_resolver as _ts_alias_resolver, ) +from desloppify.languages._framework.frameworks.registry import ( + framework_source_extensions, +) + + +def _framework_extensions() -> tuple[str, ...]: + """Framework source extensions for the node ecosystem, registry-driven. + + Resolved at call time so newly-registered specs (e.g. by tests via + ``register_framework_spec``) are picked up without module reload. + """ + return framework_source_extensions(ecosystem="node") + -_FRAMEWORK_EXTENSIONS = (".svelte", ".vue", ".astro") _IMPORT_SPEC_RE = re.compile( r"""(?:from\s+|import\s+)(?:type\s+)?['"]([^'"]+)['"]""" ) @@ -85,7 +97,7 @@ def build_dep_graph( source_root=project_root, ) - fw_files = find_source_files(path, list(_FRAMEWORK_EXTENSIONS)) + fw_files = find_source_files(path, list(_framework_extensions())) if fw_files: fw_hits = grep_files(r"""(?:\bfrom\s+['"]|\bimport\s+['"])""", fw_files) for filepath, _lineno, content in fw_hits: @@ -215,7 +227,7 @@ def build_dynamic_import_targets(path: Path, extensions: list[str]) -> set[str]: return _build_dynamic_import_targets( path, extensions, - framework_extensions=_FRAMEWORK_EXTENSIONS, + framework_extensions=_framework_extensions(), grep_files_fn=grep_files, find_source_files_fn=find_source_files, ) diff --git a/desloppify/tests/lang/common/test_bash_unused_imports.py b/desloppify/tests/lang/common/test_bash_unused_imports.py index c74dee941..d41cba215 100644 --- a/desloppify/tests/lang/common/test_bash_unused_imports.py +++ b/desloppify/tests/lang/common/test_bash_unused_imports.py @@ -4,6 +4,14 @@ import textwrap +import pytest + +from desloppify.languages._framework.treesitter import is_available + +pytestmark = pytest.mark.skipif( + not is_available(), reason="tree-sitter-language-pack not installed" +) + def _detect(tmp_path, contents: str): from desloppify.languages._framework.treesitter.analysis.unused_imports import ( diff --git a/desloppify/tests/lang/common/test_framework_registration_and_commands_split_direct.py b/desloppify/tests/lang/common/test_framework_registration_and_commands_split_direct.py index b0904efc1..516af3d54 100644 --- a/desloppify/tests/lang/common/test_framework_registration_and_commands_split_direct.py +++ b/desloppify/tests/lang/common/test_framework_registration_and_commands_split_direct.py @@ -383,7 +383,7 @@ def test_generic_registration_helpers(monkeypatch) -> None: ) monkeypatch.setattr( "desloppify.languages._framework.treesitter.imports.graph.make_ts_dep_builder", - lambda _spec, _finder: "ts-dep-builder", + lambda _spec, _finder, **_kw: "ts-dep-builder", ) _, extract_fn, dep_graph_fn, has_ts, ts_spec = registration_mod._resolve_generic_extractors( diff --git a/desloppify/tests/lang/common/test_framework_source_extensions.py b/desloppify/tests/lang/common/test_framework_source_extensions.py new file mode 100644 index 000000000..86ce85f22 --- /dev/null +++ b/desloppify/tests/lang/common/test_framework_source_extensions.py @@ -0,0 +1,142 @@ +"""Tests for the registry-driven ``framework_source_extensions`` query. + +The dep-graph builder in ``ts_build_dep_graph`` (and the TS plugin's +hand-rolled equivalent) reads framework source extensions from the +``FrameworkSpec`` registry rather than from a hardcoded tuple, so that +adding a new framework (e.g. Qwik) becomes a one-spec-file change with +zero infrastructure edits. +""" + +from __future__ import annotations + +import pytest + +from desloppify.languages._framework.frameworks.registry import ( + FRAMEWORK_SPECS, + _invalidate_extensions_cache, + framework_source_extensions, + register_framework_spec, +) +from desloppify.languages._framework.frameworks.types import ( + DetectionConfig, + FrameworkSpec, +) + + +@pytest.fixture +def restore_registry(): + """Snapshot + restore FRAMEWORK_SPECS so tests can register/unregister freely. + + ``framework_source_extensions`` caches per-ecosystem results; the cache is + invalidated automatically on ``register_framework_spec`` but bypassed by + the direct dict mutations used here for teardown, so we invalidate + explicitly to keep the cache aligned with the restored registry state. + """ + snapshot = dict(FRAMEWORK_SPECS) + yield + FRAMEWORK_SPECS.clear() + FRAMEWORK_SPECS.update(snapshot) + _invalidate_extensions_cache() + + +def test_default_node_extensions_cover_astro_svelte_vue(): + """The built-in specs contribute .astro/.svelte/.vue under the node ecosystem.""" + exts = framework_source_extensions(ecosystem="node") + assert ".astro" in exts + assert ".svelte" in exts + assert ".vue" in exts + + +def test_extensions_are_sorted_and_deduplicated(restore_registry): + """Duplicate extensions across specs collapse to a single entry, sorted.""" + register_framework_spec( + FrameworkSpec( + id="astro-duplicate-for-test", + label="Astro (duplicate for test)", + ecosystem="node", + detection=DetectionConfig(dependencies=("astro-clone",)), + source_extensions=(".astro",), + ) + ) + + exts = framework_source_extensions(ecosystem="node") + assert exts == tuple(sorted(set(exts))) + assert exts.count(".astro") == 1 + + +def test_specs_without_source_extensions_contribute_nothing(restore_registry): + """NEXTJS_SPEC and other specs that don't declare source_extensions are not + counted — their source files (.ts/.tsx/etc.) are already covered by the + host language's extensions, so they have nothing to add here. + """ + baseline = framework_source_extensions(ecosystem="node") + register_framework_spec( + FrameworkSpec( + id="scanners-only-fixture", + label="Scanners-only fixture", + ecosystem="node", + detection=DetectionConfig(dependencies=("scanners-only-fixture",)), + # source_extensions left at default () + ) + ) + + assert framework_source_extensions(ecosystem="node") == baseline + + +def test_ecosystem_filter_excludes_other_ecosystems(restore_registry): + """A python-ecosystem spec's source extensions don't leak into a node query.""" + register_framework_spec( + FrameworkSpec( + id="jinja-fixture", + label="Jinja (fixture)", + ecosystem="python", + detection=DetectionConfig(dependencies=("jinja2",)), + source_extensions=(".jinja",), + ) + ) + + node_exts = framework_source_extensions(ecosystem="node") + python_exts = framework_source_extensions(ecosystem="python") + + assert ".jinja" not in node_exts + assert ".jinja" in python_exts + + +def test_repeated_calls_return_cached_tuple(restore_registry): + """Per-ecosystem results are memoized and ``register_framework_spec`` invalidates.""" + first = framework_source_extensions(ecosystem="node") + second = framework_source_extensions(ecosystem="node") + # Same tuple identity proves we returned the cached value rather than + # re-aggregating the registry on every call. + assert first is second + + register_framework_spec( + FrameworkSpec( + id="cache-invalidation-fixture", + label="Cache invalidation fixture", + ecosystem="node", + detection=DetectionConfig(dependencies=("never-installed",)), + source_extensions=(".cache-invalidator",), + ) + ) + + third = framework_source_extensions(ecosystem="node") + assert third is not first + assert ".cache-invalidator" in third + + +def test_unfiltered_query_aggregates_across_ecosystems(restore_registry): + """Querying without an ecosystem filter returns every registered extension.""" + register_framework_spec( + FrameworkSpec( + id="jinja-fixture-2", + label="Jinja (fixture 2)", + ecosystem="python", + detection=DetectionConfig(dependencies=("jinja2-clone",)), + source_extensions=(".jinja",), + ) + ) + + all_exts = framework_source_extensions() + assert ".astro" in all_exts + assert ".jinja" in all_exts diff --git a/desloppify/tests/lang/common/test_treesitter_imports_direct.py b/desloppify/tests/lang/common/test_treesitter_imports_direct.py index 2a75eb119..d18988db3 100644 --- a/desloppify/tests/lang/common/test_treesitter_imports_direct.py +++ b/desloppify/tests/lang/common/test_treesitter_imports_direct.py @@ -82,6 +82,244 @@ def test_graph_helpers_build_internal_edges_and_builder(monkeypatch, tmp_path: P ) == {} +def test_graph_records_framework_files_as_importers( + monkeypatch, tmp_path: Path, set_project_root +) -> None: + """``.astro``/``.svelte``/``.vue`` files contribute importer edges to the + host-language graph without becoming graph nodes themselves. + + Regression for a class of false positives that used to flag every JS + module imported only from an Astro page/component as orphaned. + """ + del set_project_root # PROJECT_ROOT scoped to tmp_path via fixture + + src_dir = tmp_path / "src" + src_dir.mkdir() + target = src_dir / "config.js" + target.write_text("export const X = 1;\n", encoding="utf-8") + + astro = src_dir / "Page.astro" + astro.write_text( + "---\nimport { X } from './config';\n---\n{X}\n", + encoding="utf-8", + ) + svelte = src_dir / "Widget.svelte" + svelte.write_text( + "\n