From 9e7dba1a17c6582b08b1890ffb9c753e0edec198 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 23:19:46 +0530 Subject: [PATCH 1/3] feat(mapper): preserve Django include route prefixes --- src/mapper.test.ts | 67 ++++++++++++++++++++ src/mappers/python.ts | 138 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 185 insertions(+), 20 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9b324b4..3fae4af 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -13090,6 +13090,8 @@ add_executable(headerapp include/headers.hpp) const root = await fixtureRoot("clawpatch-python-django-routes-"); await writeFixture(root, "requirements.txt", "django\npytest\n"); await writeFixture(root, "mysite/__init__.py", ""); + await writeFixture(root, "api/__init__.py", ""); + await writeFixture(root, "api/v1/__init__.py", ""); await writeFixture( root, "mysite/urls.py", @@ -13160,6 +13162,34 @@ add_executable(headerapp include/headers.hpp) ].join("\n"), ); await writeFixture(root, "fallback/__init__.py", ""); + await writeFixture( + root, + "api/urls.py", + [ + "from django.urls import include, path", + "from . import views", + "", + "urlpatterns = [", + " path('users//', views.user_detail, name='user-detail'),", + " path('status/', views.status, name='status'),", + " path('v1/', include('api.v1.urls')),", + "]", + "", + ].join("\n"), + ); + await writeFixture( + root, + "api/v1/urls.py", + [ + "from django.urls import path", + "from . import views", + "", + "urlpatterns = [", + " path('ping/', views.ping, name='ping'),", + "]", + "", + ].join("\n"), + ); await writeFixture( root, "fallback/urls.py", @@ -13172,8 +13202,12 @@ add_executable(headerapp include/headers.hpp) "", ].join("\n"), ); + await writeFixture(root, "api/views.py", "def user_detail():\n pass\n"); + await writeFixture(root, "api/v1/views.py", "def ping():\n pass\n"); await writeFixture(root, "mysite/views.py", "class SignupView:\n pass\n"); await writeFixture(root, "fallback/views.py", "def dependency_only():\n pass\n"); + await writeFixture(root, "api/test_urls.py", "def test_api_urls():\n pass\n"); + await writeFixture(root, "api/v1/test_urls.py", "def test_api_v1_urls():\n pass\n"); await writeFixture(root, "mysite/test_urls.py", "def test_urls():\n pass\n"); const project = await detectProject(root); @@ -13193,6 +13227,10 @@ add_executable(headerapp include/headers.hpp) "Django route /signup/", "Django route /admin/", "Django route /api/", + "Django route /api/users/:pk/", + "Django route /api/status/", + "Django route /api/v1/", + "Django route /api/v1/ping/", "Django route /tuple-api/", "Django route /dependency-only/", "Django route /legacy/:slug/", @@ -13211,6 +13249,28 @@ add_executable(headerapp include/headers.hpp) { path: "mysite/test_urls.py", command: "pytest" }, ]); expect(byTitle("Django route /api/")?.entrypoints[0]?.symbol).toBe("api.urls"); + expect(byTitle("Django route /api/users/:pk/")?.entrypoints[0]).toMatchObject({ + path: "api/urls.py", + symbol: "views.user_detail", + route: "/api/users/:pk/", + }); + expect(byTitle("Django route /api/users/:pk/")?.tests).toEqual([ + { path: "api/test_urls.py", command: "pytest" }, + { path: "api/v1/test_urls.py", command: "pytest" }, + ]); + expect(byTitle("Django route /api/v1/")?.entrypoints[0]).toMatchObject({ + path: "api/urls.py", + symbol: "api.v1.urls", + route: "/api/v1/", + }); + expect(byTitle("Django route /api/v1/ping/")?.entrypoints[0]).toMatchObject({ + path: "api/v1/urls.py", + symbol: "views.ping", + route: "/api/v1/ping/", + }); + expect(byTitle("Django route /api/v1/ping/")?.tests).toEqual([ + { path: "api/v1/test_urls.py", command: "pytest" }, + ]); expect(byTitle("Django route /tuple-api/")?.entrypoints[0]?.symbol).toBeNull(); expect(byTitle("Django route /signup/")?.entrypoints[0]?.symbol).toBe("SignupView.as_view"); expect(byTitle("Django route /admin/")?.entrypoints[0]?.symbol).toBe("admin.site.urls"); @@ -13221,6 +13281,10 @@ add_executable(headerapp include/headers.hpp) }); expect(byTitle("Django route /accounts/password/reset/")?.trustBoundaries).toContain("auth"); expect(byTitle("Django route /signup/")?.trustBoundaries).toContain("auth"); + expect(routes.filter((feature) => feature.title === "Django route /users/:pk/")).toHaveLength( + 1, + ); + expect(byTitle("Django route /users/:pk/")?.entrypoints[0]?.path).toBe("mysite/urls.py"); expect(byTitle("Django route /users/:pk/")?.trustBoundaries).not.toContain("auth"); expect(byTitle("Django route /orders/")?.trustBoundaries).not.toContain("auth"); expect(titles).not.toContain("Django route /tenant/"); @@ -13233,6 +13297,9 @@ add_executable(headerapp include/headers.hpp) expect(titles).not.toContain("Django route /indented-docs-only/"); expect(titles).not.toContain("Django route /local-only/"); expect(titles).not.toContain("Django route /helper/"); + expect(titles).not.toContain("Django route /status/"); + expect(titles).not.toContain("Django route /v1/"); + expect(titles).not.toContain("Django route /v1/ping/"); expect(titles).not.toContain("Django route /unused/"); }); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 1276dc1..7bbbb8a 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -397,36 +397,134 @@ async function djangoRouteSeeds( ...(await walk(root, await pythonSourceRoots(root))).filter(isReviewablePythonSourceFile), ]); const seeds: FeatureSeed[] = []; + const routesByFile = new Map(); for (const filePath of routeFiles) { const source = await readFile(join(root, filePath), "utf8"); if (!sourceLooksDjangoUrls(filePath, source, hasDjangoDependency)) { continue; } - for (const route of parseDjangoRoutes(filePath, source)) { - const tests = associatedTests([route.filePath], testFiles, testCommand); - seeds.push({ - title: `Django route ${route.routePath}`, - summary: djangoRouteSummary(route), - kind: "route", - source: "python-django-route", - confidence: "high", - entryPath: route.filePath, - symbol: route.symbol, - route: route.routePath, - command: null, - ownedFiles: [{ path: route.filePath, reason: "Django URL route declaration" }], - contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })), - tests, - tags: ["python", "django", "route"], - trustBoundaries: djangoRouteTrustBoundaries(route), - testCommand, - skipNearbyTests: true, - }); + routesByFile.set(filePath, parseDjangoRoutes(filePath, source)); + } + const includedRouteFiles = await djangoIncludedRouteFiles(root, routesByFile); + const routeFilesToSeed = [...routesByFile.keys()].filter( + (filePath) => !includedRouteFiles.has(filePath), + ); + for (const filePath of routeFilesToSeed) { + const routes = routesByFile.get(filePath) ?? []; + for (const route of routes) { + for (const expanded of await expandDjangoIncludedRoutes( + root, + route, + routesByFile, + new Set([filePath]), + )) { + const tests = associatedTests([expanded.filePath], testFiles, testCommand); + seeds.push({ + title: `Django route ${expanded.routePath}`, + summary: djangoRouteSummary(expanded), + kind: "route", + source: "python-django-route", + confidence: "high", + entryPath: expanded.filePath, + symbol: expanded.symbol, + route: expanded.routePath, + command: null, + ownedFiles: [{ path: expanded.filePath, reason: "Django URL route declaration" }], + contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })), + tests, + tags: ["python", "django", "route"], + trustBoundaries: djangoRouteTrustBoundaries(expanded), + testCommand, + skipNearbyTests: true, + }); + } } } return seeds; } +async function djangoIncludedRouteFiles( + root: string, + routesByFile: Map, +): Promise> { + const included = new Set(); + for (const routes of routesByFile.values()) { + for (const route of routes) { + if (!route.include || route.symbol === null) { + continue; + } + const includePath = await resolveDjangoIncludeModule(root, route.symbol); + if (includePath !== null && routesByFile.has(includePath)) { + included.add(includePath); + } + } + } + return included; +} + +async function expandDjangoIncludedRoutes( + root: string, + route: DjangoRoute, + routesByFile: Map, + visited: Set, +): Promise { + const routes = [route]; + if (!route.include || route.symbol === null) { + return routes; + } + const includePath = await resolveDjangoIncludeModule(root, route.symbol); + if (includePath === null || visited.has(includePath)) { + return routes; + } + let includedRoutes = routesByFile.get(includePath); + if (includedRoutes === undefined) { + const source = await readFile(join(root, includePath), "utf8"); + includedRoutes = parseDjangoRoutes(includePath, source); + routesByFile.set(includePath, includedRoutes); + } + const nextVisited = new Set([...visited, includePath]); + for (const included of includedRoutes) { + const mounted = { + ...included, + routePath: joinDjangoRoutePaths(route.routePath, included.routePath), + }; + routes.push(...(await expandDjangoIncludedRoutes(root, mounted, routesByFile, nextVisited))); + } + return routes; +} + +async function resolveDjangoIncludeModule( + root: string, + moduleName: string, +): Promise { + if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u.test(moduleName)) { + return null; + } + const modulePath = `${moduleName.replace(/\./gu, "/")}.py`; + const candidates = new Set([modulePath]); + for (const sourceRoot of await pythonSourceRoots(root)) { + candidates.add(`${sourceRoot}/${modulePath}`); + } + for (const candidate of candidates) { + if (await isSafeFile(root, join(root, candidate))) { + return candidate; + } + } + return null; +} + +function joinDjangoRoutePaths(prefix: string, route: string): string { + if (prefix === "/") { + return route; + } + if (route === "/") { + return prefix; + } + return normalizeDjangoRoutePath( + `${prefix.replace(/^\/+|\/+$/gu, "")}/${route.replace(/^\/+/u, "")}`, + ); +} + function sourceLooksDjangoUrls( filePath: string, source: string, From 12a7be1e50ea1130ee5a6614717c8193ead2f391 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 23:57:00 +0530 Subject: [PATCH 2/3] fix(mapper): preserve slashless Django includes --- src/mapper.test.ts | 12 ++++++++++++ src/mappers/python.ts | 4 +--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3fae4af..5391afb 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -13147,6 +13147,7 @@ add_executable(headerapp include/headers.hpp) " path('signup/', SignupView.as_view(), name='signup'),", " path('admin/', admin.site.urls),", " path('api/', include('api.urls')),", + " path('slashless', include('api.urls')),", " path('tuple-api/', include(('tuple.urls', 'tuple'), namespace='tuple')),", " re_path(r'^legacy/(?P[-\\w]+)/$', views.legacy, name='legacy'),", " url(r'^old/(?P\\d+)/$', views.old_detail),", @@ -13231,6 +13232,11 @@ add_executable(headerapp include/headers.hpp) "Django route /api/status/", "Django route /api/v1/", "Django route /api/v1/ping/", + "Django route /slashless", + "Django route /slashlessusers/:pk/", + "Django route /slashlessstatus/", + "Django route /slashlessv1/", + "Django route /slashlessv1/ping/", "Django route /tuple-api/", "Django route /dependency-only/", "Django route /legacy/:slug/", @@ -13249,11 +13255,17 @@ add_executable(headerapp include/headers.hpp) { path: "mysite/test_urls.py", command: "pytest" }, ]); expect(byTitle("Django route /api/")?.entrypoints[0]?.symbol).toBe("api.urls"); + expect(byTitle("Django route /slashless")?.entrypoints[0]?.symbol).toBe("api.urls"); expect(byTitle("Django route /api/users/:pk/")?.entrypoints[0]).toMatchObject({ path: "api/urls.py", symbol: "views.user_detail", route: "/api/users/:pk/", }); + expect(byTitle("Django route /slashlessusers/:pk/")?.entrypoints[0]).toMatchObject({ + path: "api/urls.py", + symbol: "views.user_detail", + route: "/slashlessusers/:pk/", + }); expect(byTitle("Django route /api/users/:pk/")?.tests).toEqual([ { path: "api/test_urls.py", command: "pytest" }, { path: "api/v1/test_urls.py", command: "pytest" }, diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 7bbbb8a..11628d7 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -520,9 +520,7 @@ function joinDjangoRoutePaths(prefix: string, route: string): string { if (route === "/") { return prefix; } - return normalizeDjangoRoutePath( - `${prefix.replace(/^\/+|\/+$/gu, "")}/${route.replace(/^\/+/u, "")}`, - ); + return normalizeDjangoRoutePath(`${prefix.replace(/^\/+/u, "")}${route.replace(/^\/+/u, "")}`); } function sourceLooksDjangoUrls( From 9aae97f720903b4ffff7eeaaa84a5b8c9dc1937e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 09:41:54 +0100 Subject: [PATCH 3/3] docs(mapper): note Django include prefix expansion --- CHANGELOG.md | 1 + docs/feature-mapping.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc3b18..f581c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi. - Improved Node route mapping to preserve literal Express and Hono mount prefixes, thanks @rohitjavvadi. - Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi. +- Improved Django route mapping to preserve literal `include()` route prefixes, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi. - Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 296f1a8..8321780 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -166,8 +166,8 @@ pytest files; Flask `@*.route(...)` handlers; FastAPI `@*.get(...)` / `re_path(...)`, and legacy `url(...)` declarations. Flask and FastAPI route methods are read from list, tuple, or set literals. FastAPI paths can be positional strings or literal `path=` keywords. Django route paths are normalized -from literal route strings and simple named regex groups; includes are mapped as -their own URL groups without recursively expanding imported URL configs. Default +from literal route strings and simple named regex groups, and literal +`include("module.urls")` routes are expanded under their mount prefixes. Default Python command detection covers pytest, ruff, mypy, pyright, and black. Ruby mapping covers project metadata, executables, source groups, RSpec and