Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions desloppify/engine/detectors/orphaned.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,79 @@
_NEXTJS_EXTENSIONS: set[str] = {".ts", ".tsx", ".js", ".jsx"}


# ---------------------------------------------------------------------------
# React Router / Remix convention files
# ---------------------------------------------------------------------------

# Everything under app/routes/ is a route module, loaded by the framework's
# file-based router and imported by nothing.
_REACT_ROUTER_ROUTE_DIRS: tuple[str, ...] = ("routes",)

# Framework entry points that sit beside the routes directory.
_REACT_ROUTER_CONVENTIONS: set[str] = {
"root",
"entry.client",
"entry.server",
}

_REACT_ROUTER_CONFIGS: tuple[str, ...] = (
"react-router.config.js",
"react-router.config.mjs",
"react-router.config.ts",
"remix.config.js",
"remix.config.mjs",
"remix.config.ts",
)


def _detect_react_router_project(path: Path) -> bool:
"""Return True if the scan root looks like a React Router or Remix project.

A config file is the cheap signal. Failing that, the dependency is checked,
because the framework's own template ships a Vite config rather than a
react-router.config file.
"""
for name in _REACT_ROUTER_CONFIGS:
if (path / name).exists():
return True

package_json = path / "package.json"
if package_json.exists():
try:
text = package_json.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return '"@react-router/' in text or '"@remix-run/' in text
return False


def _is_react_router_convention_entry(rel_path: str) -> bool:
"""Return True if *rel_path* is a React Router / Remix convention file.

Route modules and the framework entry points have no importers by design —
the router loads them from the filesystem — so reporting them as orphaned
is a false positive on every project of this shape.
"""
p = Path(rel_path)
if p.suffix not in _NEXTJS_EXTENSIONS:
return False

parts = p.parts

# Any file beneath an app/routes/ (or src/routes/) directory.
for routes_dir in _REACT_ROUTER_ROUTE_DIRS:
if routes_dir in parts[:-1]:
return True

# root.jsx, entry.client.jsx, entry.server.jsx beside the routes directory.
# `.stem` only strips the last suffix, so entry.client.jsx stems to
# "entry.client", which is exactly what is being matched.
if p.stem in _REACT_ROUTER_CONVENTIONS and len(parts) <= 3:
return True

return False


def _detect_nextjs_project(path: Path) -> bool:
"""Return True if the scan root looks like a Next.js project."""
for name in ("next.config.js", "next.config.mjs", "next.config.ts"):
Expand Down Expand Up @@ -143,6 +216,9 @@ def detect_orphaned_files(
is_nextjs = (
resolved_options.detect_frameworks and _detect_nextjs_project(path)
)
is_react_router = (
resolved_options.detect_frameworks and _detect_react_router_project(path)
)

dynamic_targets = (
dynamic_import_finder(path, extensions) if dynamic_import_finder else set()
Expand All @@ -166,6 +242,9 @@ def detect_orphaned_files(
if is_nextjs and _is_nextjs_convention_entry(r):
continue

if is_react_router and _is_react_router_convention_entry(r):
continue

if dynamic_targets and _is_dynamically_imported(
filepath, dynamic_targets, alias_resolver
):
Expand Down
73 changes: 73 additions & 0 deletions desloppify/tests/detectors/test_orphaned.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from desloppify.engine.detectors.orphaned import (
OrphanedDetectionOptions,
_detect_nextjs_project,
_detect_react_router_project,
_has_dunder_all,
_is_dynamically_imported,
_is_nextjs_convention_entry,
_is_react_router_convention_entry,
detect_orphaned_files,
)

Expand Down Expand Up @@ -683,3 +685,74 @@ def test_detect_frameworks_false_disables(self, tmp_path):
)

assert len(entries) == 1


# ===================================================================
# React Router / Remix framework awareness
# ===================================================================


class TestDetectReactRouterProject:
"""Unit tests for _detect_react_router_project."""

def test_react_router_config(self, tmp_path):
(tmp_path / "react-router.config.ts").write_text("export default {}")
assert _detect_react_router_project(tmp_path) is True

def test_remix_config(self, tmp_path):
(tmp_path / "remix.config.js").write_text("module.exports = {}")
assert _detect_react_router_project(tmp_path) is True

def test_dependency_in_package_json(self, tmp_path):
# The framework template ships a Vite config rather than a
# react-router.config file, so the dependency is the reliable signal.
(tmp_path / "package.json").write_text(
'{"dependencies": {"@react-router/node": "^7.0.0"}}'
)
assert _detect_react_router_project(tmp_path) is True

def test_remix_dependency(self, tmp_path):
(tmp_path / "package.json").write_text(
'{"dependencies": {"@remix-run/node": "^2.0.0"}}'
)
assert _detect_react_router_project(tmp_path) is True

def test_unrelated_project(self, tmp_path):
(tmp_path / "package.json").write_text('{"dependencies": {"express": "^4.0.0"}}')
assert _detect_react_router_project(tmp_path) is False

def test_no_package_json(self, tmp_path):
assert _detect_react_router_project(tmp_path) is False


class TestIsReactRouterConventionEntry:
"""Unit tests for _is_react_router_convention_entry."""

def test_route_module(self):
assert _is_react_router_convention_entry("app/routes/app.settings.jsx") is True

def test_nested_route_module(self):
assert _is_react_router_convention_entry("app/routes/_index/route.jsx") is True

def test_src_routes(self):
assert _is_react_router_convention_entry("src/routes/dashboard.tsx") is True

def test_root_module(self):
assert _is_react_router_convention_entry("app/root.jsx") is True

def test_server_entry(self):
# `.stem` strips only the last suffix, so this stems to "entry.server".
assert _is_react_router_convention_entry("app/entry.server.jsx") is True

def test_client_entry(self):
assert _is_react_router_convention_entry("app/entry.client.tsx") is True

def test_ordinary_module_is_not_an_entry(self):
# The whole point: a genuinely orphaned file must still be reported.
assert _is_react_router_convention_entry("app/db.server.js") is False

def test_a_file_merely_named_root_deeper_in_the_tree(self):
assert _is_react_router_convention_entry("app/lib/nested/root.js") is False

def test_non_javascript_extension(self):
assert _is_react_router_convention_entry("app/routes/styles.css") is False