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\n", + encoding="utf-8", + ) + + 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) + + spec = SimpleNamespace( + grammar="javascript", + import_query="imports", + resolve_import=lambda *_a, **_k: str(target.resolve()), + ) + target_abs = str(target.resolve()) + + graph = graph_mod.ts_build_dep_graph(tmp_path, spec, [target_abs]) + assert graph[target_abs]["importer_count"] == 0 + + def test_import_normalize_helpers_strip_comments_and_log_lines() -> None: cached = normalize_mod._get_log_patterns((r"logger\.",)) assert cached is normalize_mod._get_log_patterns((r"logger\.",)) From 9baba25e494b8638d6883dabd409c7d7c571283b Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 28 May 2026 16:51:54 +0200 Subject: [PATCH 2/3] chore(ci): unblock CI by gating bash tests on tree-sitter; sort review prompt glob ## The problem CI has been red on `main` since 2026-04-06, and on every PR since, because of 5 pre-existing test failures that are unrelated to feature work. They block green builds without any way to fix them inside a feature PR. Two distinct root causes: 1. Three bash tests in `desloppify/tests/lang/common/test_bash_unused_imports.py` call `detect_unused_imports`, which parses via tree-sitter. When `tree-sitter-language-pack` is not installed (the case in the `tests-core` job, but not `tests-full`), the function returns `[]`. The tests then assert specific findings and fail. 2. Two review tests in `desloppify/tests/review/test_review_commands.py` and its integration counterpart use `list(runs_dir.glob("*/prompts/batch-*.md"))` without sorting. The glob order is filesystem-dependent: on macOS it happens to return `batch-1.md` (the batch with `historical_issue_focus`) first, on Linux it returns `batch-2.md` first. The assertion `"Previously flagged issues" in prompt_text` then fails on Linux because that string only appears in `batch-1.md`. ## The solution Two small fixes: 1. Gate the bash test file on tree-sitter availability with the existing pattern from `desloppify/tests/lang/common/test_treesitter.py`: ```python pytestmark = pytest.mark.skipif( not is_available(), reason="tree-sitter-language-pack not installed", ) ``` Without tree-sitter the tests skip cleanly; with it they pass as before. 2. Wrap the review test's glob in `sorted(...)`: ```python prompt_files = sorted(runs_dir.glob("*/prompts/batch-*.md")) ``` This makes the test deterministic across operating systems. `batch-1.md` always sorts first; the assertion that depends on its content is reliable. ## Why this is in the same PR Both fixes are independent of the Astro/Svelte/Vue work in this PR and could be cherry-picked back to `main` separately. Bundling them here lets the PR actually go green without waiting for a separate maintenance PR. --- desloppify/tests/lang/common/test_bash_unused_imports.py | 8 ++++++++ desloppify/tests/review/review_commands_cases.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) 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/review/review_commands_cases.py b/desloppify/tests/review/review_commands_cases.py index 0158e4fd8..2b9c0c2e1 100644 --- a/desloppify/tests/review/review_commands_cases.py +++ b/desloppify/tests/review/review_commands_cases.py @@ -873,7 +873,10 @@ def test_do_run_batches_dry_run_generates_packet_and_prompts( assert len(packet_files) == 1 blind_packet = tmp_path / ".desloppify" / "review_packet_blind.json" assert blind_packet.exists() - prompt_files = list(runs_dir.glob("*/prompts/batch-*.md")) + # Sort: prompt files are named batch-1.md, batch-2.md; glob order is + # filesystem-dependent (differs Linux vs macOS) and the assertions + # below assume batch-1 (high_level_elegance, with historical_focus) first. + prompt_files = sorted(runs_dir.glob("*/prompts/batch-*.md")) assert len(prompt_files) == 2 prompt_text = prompt_files[0].read_text() assert "Blind packet:" in prompt_text From 28ffdcb08b53eeb93bed3d98680f6de454fcdf9a Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 28 May 2026 17:57:16 +0200 Subject: [PATCH 3/3] fix: address /octo:review round-2 findings on framework importer scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes uncovered by multi-LLM review of the Astro/Svelte/Vue support added earlier on this branch: V1 (specs): drop the `vite` token from Svelte and Vue script_pattern regex. Vite is a generic bundler; including it made plain Vite+React projects match both Svelte and Vue framework detection. The framework-specific tokens (`svelte-kit`, `nuxt`, `vue-cli-service`) already disambiguate correctly. V2 (framework scan honors opts.exclude): the host file_finder is built via make_file_finder(path_extensions, opts.exclude), but the framework scan in _add_framework_importers called find_source_files directly with no options. Threaded a framework_file_finder callable through make_ts_dep_builder and ts_build_dep_graph so registration.py supplies an exclude-aware finder built with the same opts.exclude as the host. V3 (skip commented imports): added _strip_inline_comments to remove `//`, `/* */`, and `` regions from each grep_files row before running the import-specifier regex. Without this, lines like `// import './foo'` in an Astro frontmatter or `` in a Vue template created spurious importer edges and masked genuinely orphan modules — the orphan detector's primary failure mode. V4 (test gaps): added regression tests covering commented imports (all three comment styles), Vue \n" + "\n", + encoding="utf-8", + ) + type_only = src_dir / "Types.astro" + type_only.write_text( + "---\nimport type { X } from './config';\n---\n\n", + encoding="utf-8", + ) + + graph = graph_mod.ts_build_dep_graph( + tmp_path, + spec, + [target_abs], + framework_extensions=(".astro", ".vue"), + ) + + vue_abs = str(vue.resolve()) + type_only_abs = str(type_only.resolve()) + assert vue_abs in graph[target_abs]["importers"] + assert type_only_abs in graph[target_abs]["importers"] + assert graph[target_abs]["importer_count"] == 2 + + +def test_framework_file_finder_honors_caller_supplied_exclusions( + monkeypatch, tmp_path: Path, set_project_root +) -> None: + """When ``framework_file_finder`` is provided, only the files it returns + contribute importer edges — proving exclude-aware finders can suppress + framework files in user-excluded directories (e.g. ``examples/``). + """ + del set_project_root + + target_abs, src_dir, spec = _make_fw_test_setup(monkeypatch, tmp_path) + + included = src_dir / "Real.astro" + included.write_text( + "---\nimport { X } from './config';\n---\n\n", + encoding="utf-8", + ) + excluded_dir = tmp_path / "examples" + excluded_dir.mkdir() + excluded = excluded_dir / "Demo.astro" + excluded.write_text( + "---\nimport { X } from './config';\n---\n\n", + encoding="utf-8", + ) + + def fw_finder(_path: Path) -> list[str]: + return [str(included)] + + graph = graph_mod.ts_build_dep_graph( + tmp_path, + spec, + [target_abs], + framework_extensions=(".astro",), + framework_file_finder=fw_finder, + ) + + assert graph[target_abs]["importer_count"] == 1 + assert str(included.resolve()) in graph[target_abs]["importers"] + assert str(excluded.resolve()) not in graph[target_abs]["importers"] + + def test_import_normalize_helpers_strip_comments_and_log_lines() -> None: cached = normalize_mod._get_log_patterns((r"logger\.",)) assert cached is normalize_mod._get_log_patterns((r"logger\.",))