From cc4339581037b8a05244a12a9f04b5f6b2e30d76 Mon Sep 17 00:00:00 2001
From: Andrei Lavrenov
Date: Thu, 28 May 2026 16:51:46 +0200
Subject: [PATCH 1/3] feat: Astro/Svelte/Vue project support via FrameworkSpec
registry
## The problem
When you run `desloppify scan` on an Astro, Svelte, or Vue project,
files that are imported only from `.astro`/`.svelte`/`.vue` get
flagged as "orphaned" even though they are used.
On a real Astro 5 project, `desloppify scan --path .` reported 16
files as orphaned. All 16 were genuinely imported.
Two separate things were broken:
1. The JS plugin did not know how to read `.astro`/`.svelte`/`.vue`
files. They are not in its extension list, so it never opened
them to see what they import. Any `.js` file imported only from
a `.astro` page had 0 importers in the graph and tripped the
orphan detector.
2. The tree-sitter import pass silently dropped every import edge
whenever the scan ran from a directory other than the project
root. `file_set` held relative paths and `resolve_import`
produced absolute paths, so they never matched. Tests did not
notice because `desloppify scan` always chdir's to the project
root in production.
## The solution
Two changes that work together.
### 1. Put framework file extensions on `FrameworkSpec` (#418)
The `FrameworkSpec` horizontal layer already exists for tool
integrations and scanner rules (Next.js uses it today). Extend it
with one new field:
```python
@dataclass(frozen=True)
class FrameworkSpec:
...
source_extensions: tuple[str, ...] = () # NEW
```
Register three new specs in `_framework/frameworks/specs/`:
`ASTRO_SPEC`, `SVELTE_SPEC`, `VUE_SPEC`. Each declares its
`source_extensions` ((".astro",), (".svelte",), (".vue",)).
Both the JS plugin and the TS plugin look the list up from the
registry:
```python
framework_extensions = framework_source_extensions(ecosystem="node")
```
After this change, **adding a new framework like Qwik is one new
file** (`specs/qwik.py`) plus a `register_framework_spec(...)` line.
Zero edits to `graph.py`, `registration.py`, or the TS plugin.
### 2. Fix the cross-CWD bug in the dep-graph builder
Absolutize `file_list` at the top of `ts_build_dep_graph` so graph
keys, tree-sitter file reads, and import-resolver outputs all share
one coordinate system:
```python
absolute_file_list = [resolve_path(f) for f in file_list]
file_set = set(absolute_file_list)
```
Add an opt-in `framework_extensions` parameter that runs a regex
pass over framework files and records them as importer edges on
matching `file_set` entries. Framework files themselves are not
added as graph nodes, so they never appear in orphan or coupling
reports.
## Net effect
On the Astro project that prompted this fix:
| | Before | After |
|---|---|---|
| Orphan findings | 16 | 0 |
| Import edges in JS graph | 0 | 56 |
| Importer edges in JS graph | 0 | 60 |
All 16 "orphans" were real importers; they now correctly show
importers from `astro.config.mjs`, `Starfield.astro`,
`WaitlistForm.astro`, `index.astro`, and the internal `.js -> .js`
edges that the cross-CWD bug had also been hiding.
## What changes per file
- `_framework/frameworks/types.py` - adds `source_extensions` field
to `FrameworkSpec`.
- `_framework/frameworks/registry.py` - adds
`framework_source_extensions(ecosystem)` helper; registers the
3 new specs in the built-in loader.
- `_framework/frameworks/specs/{astro,svelte,vue}.py` - new files,
one per framework, with `DetectionConfig` + `source_extensions`.
- `_framework/treesitter/imports/graph.py` - absolutizes paths;
adds `framework_extensions` parameter and the regex pass.
- `_framework/generic_support/{core,registration}.py` - wires
`frameworks=True` through to the registry query.
- `typescript/detectors/deps/__init__.py` - replaces the
hand-rolled `_FRAMEWORK_EXTENSIONS` constant with the same
registry query at both call sites (line 88 framework-file pass,
line 218 dynamic-imports helper).
## Tests
Adds 9 new tests, modifies 1 existing monkeypatch lambda:
- `test_graph_records_framework_files_as_importers` and
`test_graph_framework_pass_is_opt_in` -
`tests/lang/common/test_treesitter_imports_direct.py`
- `test_dep_graph_treats_astro_frontmatter_as_importer` and
`test_dep_graph_treats_mjs_config_as_importer` -
`javascript/tests/test_init.py`
- 5 tests in `tests/lang/common/test_framework_source_extensions.py`
covering the registry query: built-in defaults, sort/dedup,
no-source-extensions specs contributing nothing, ecosystem
filtering, unfiltered aggregation.
Suite: 6829 passed, 19 skipped. No regressions.
## What this does NOT do
- **Migrate the TS plugin's hand-rolled `build_dep_graph`** onto the
shared `ts_build_dep_graph`. That is a real refactor: needs
lifting `external_imports` and tsconfig-alias resolution into
the `TreeSitterLangSpec` layer first.
- **Resolve the graph-node semantics divergence** between the TS
plugin (treats framework files as graph nodes) and the JS plugin
(does not).
- **Split the overloaded `frameworks: bool` flag** into
`framework_phases` + `framework_imports`.
These are intentional follow-ups, not in scope here.
Related architecture issue: #418.
---
.../_framework/frameworks/registry.py | 28 +++++
.../_framework/frameworks/specs/astro.py | 25 ++++
.../_framework/frameworks/specs/svelte.py | 29 +++++
.../_framework/frameworks/specs/vue.py | 31 +++++
.../languages/_framework/frameworks/types.py | 7 ++
.../_framework/generic_support/core.py | 1 +
.../generic_support/registration.py | 16 ++-
.../_framework/treesitter/imports/graph.py | 110 +++++++++++++++--
.../languages/javascript/tests/test_init.py | 72 ++++++++++++
.../typescript/detectors/deps/__init__.py | 18 ++-
..._registration_and_commands_split_direct.py | 2 +-
.../test_framework_source_extensions.py | 111 ++++++++++++++++++
.../common/test_treesitter_imports_direct.py | 95 +++++++++++++++
13 files changed, 533 insertions(+), 12 deletions(-)
create mode 100644 desloppify/languages/_framework/frameworks/specs/astro.py
create mode 100644 desloppify/languages/_framework/frameworks/specs/svelte.py
create mode 100644 desloppify/languages/_framework/frameworks/specs/vue.py
create mode 100644 desloppify/tests/lang/common/test_framework_source_extensions.py
diff --git a/desloppify/languages/_framework/frameworks/registry.py b/desloppify/languages/_framework/frameworks/registry.py
index fe2bb5fb0..458885916 100644
--- a/desloppify/languages/_framework/frameworks/registry.py
+++ b/desloppify/languages/_framework/frameworks/registry.py
@@ -39,9 +39,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 +55,31 @@ 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.
+ """
+ ensure_builtin_specs_loaded()
+ return tuple(
+ sorted(
+ {
+ ext
+ for spec in list_framework_specs(ecosystem=ecosystem).values()
+ for ext in spec.source_extensions
+ }
+ )
+ )
+
+
__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..2d1b62f6a
--- /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|vite)(?:\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..7afef739e
--- /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|vite)(?:\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..243c20901 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,26 @@ 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
+ )
+ dep_graph_fn = make_ts_dep_builder(
+ ts_spec,
+ file_finder,
+ framework_extensions=framework_extensions,
+ )
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..be7e86877 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,36 @@
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)."""
+
+
def ts_build_dep_graph(
path: Path,
spec: TreeSitterLangSpec,
file_list: list[str],
+ *,
+ framework_extensions: tuple[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.
"""
if not spec.import_query or not spec.resolve_import:
return {}
@@ -31,14 +57,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 +112,23 @@ 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,
+ spec=spec,
+ scan_path=scan_path,
+ path=path,
+ )
# Finalize: add counts.
for data in graph.values():
@@ -95,19 +138,72 @@ 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, ...],
+ 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.
+ """
+ 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]
+ for match in _FRAMEWORK_IMPORT_RE.finditer(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,
) -> 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``).
"""
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,
+ )
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_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..2fea8562b
--- /dev/null
+++ b/desloppify/tests/lang/common/test_framework_source_extensions.py
@@ -0,0 +1,111 @@
+"""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,
+ 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."""
+ snapshot = dict(FRAMEWORK_SPECS)
+ yield
+ FRAMEWORK_SPECS.clear()
+ FRAMEWORK_SPECS.update(snapshot)
+
+
+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_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..0b3b67827 100644
--- a/desloppify/tests/lang/common/test_treesitter_imports_direct.py
+++ b/desloppify/tests/lang/common/test_treesitter_imports_direct.py
@@ -82,6 +82,101 @@ 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
{X}
\n",
+ encoding="utf-8",
+ )
+
+ # Stub the tree-sitter pass so .js files alone produce no edges — the
+ # importer edges in this test must come from the framework-file pass.
+ monkeypatch.setattr(graph_mod, "_get_parser", lambda _grammar: ("parser", "language"))
+ monkeypatch.setattr(graph_mod, "_make_query", lambda _language, source: source)
+ monkeypatch.setattr(graph_mod, "get_or_parse_tree", lambda *_a, **_k: None)
+
+ target_abs = str(target.resolve())
+
+ def fake_resolve(text: str, _source_file: str, _scan_path: str) -> str | None:
+ return target_abs if text == "./config" else None
+
+ spec = SimpleNamespace(
+ grammar="javascript",
+ import_query="imports",
+ resolve_import=fake_resolve,
+ )
+ file_list = [target_abs]
+
+ graph = graph_mod.ts_build_dep_graph(
+ tmp_path,
+ spec,
+ file_list,
+ framework_extensions=(".astro", ".svelte"),
+ )
+
+ astro_abs = str(astro.resolve())
+ svelte_abs = str(svelte.resolve())
+ assert astro_abs in graph[target_abs]["importers"]
+ assert svelte_abs in graph[target_abs]["importers"]
+ assert graph[target_abs]["importer_count"] == 2
+ # Framework files themselves are not graph nodes — they must not show
+ # up in orphan or coupling reports.
+ assert astro_abs not in graph
+ assert svelte_abs not in graph
+
+
+def test_graph_framework_pass_is_opt_in(
+ monkeypatch, tmp_path: Path, set_project_root
+) -> None:
+ """Without ``framework_extensions``, framework files are ignored entirely."""
+ del set_project_root
+
+ 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