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 diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9008313..a401302 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -13397,6 +13397,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", @@ -13452,6 +13454,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),", @@ -13467,6 +13470,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", @@ -13479,8 +13510,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); @@ -13500,6 +13535,15 @@ 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 /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/", @@ -13518,6 +13562,34 @@ 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" }, + ]); + 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"); @@ -13528,6 +13600,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/"); @@ -13540,6 +13616,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 63f0e5a..f2badbc 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -397,36 +397,132 @@ 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(/^\/+/u, "")}${route.replace(/^\/+/u, "")}`); +} + function sourceLooksDjangoUrls( filePath: string, source: string,