From b552c1f9ff08d83dfadbc63f3e6b0dc47449f106 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 4 Aug 2026 00:32:22 +0200 Subject: [PATCH] feat: treat React Router / Remix route modules as entry points Route modules and framework entry points have no importers by design -- the file-based router loads them from the filesystem -- so every one of them is reported as an orphaned file on every project of this shape. Mirrors the existing Next.js App Router handling rather than adding a new mechanism: detect the framework at the scan root, then exempt its convention files. Detection prefers a react-router.config.* or remix.config.* file and falls back to checking package.json for @react-router/* or @remix-run/*, because the framework's own template ships a Vite config rather than a react-router.config. Exempted: anything beneath an app/routes/ or src/routes/ directory, plus root, entry.client and entry.server beside it. An ordinary module such as app/db.server.js is deliberately still reported -- a genuinely orphaned file has to stay visible, which is what the negative tests cover. On a real React Router project this drops orphaned findings from 49 to 27. --- desloppify/engine/detectors/orphaned.py | 79 +++++++++++++++++++++ desloppify/tests/detectors/test_orphaned.py | 73 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/desloppify/engine/detectors/orphaned.py b/desloppify/engine/detectors/orphaned.py index 75e8a2cea..d2abea929 100644 --- a/desloppify/engine/detectors/orphaned.py +++ b/desloppify/engine/detectors/orphaned.py @@ -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"): @@ -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() @@ -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 ): diff --git a/desloppify/tests/detectors/test_orphaned.py b/desloppify/tests/detectors/test_orphaned.py index dff923f1b..ab01b8113 100644 --- a/desloppify/tests/detectors/test_orphaned.py +++ b/desloppify/tests/detectors/test_orphaned.py @@ -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, ) @@ -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