diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af61e4..5ab47c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `clawpatch review --export-tribunal-ledger` to emit review findings as JSONL for downstream ledger ingestion, thanks @dpdanpittman. - Added `clawpatch review --prompt-file` to append extra reviewer guidance from a file or stdin, thanks @dpdanpittman. - Added deterministic Express, Fastify, and Hono route mapping for Node projects, thanks @rohitjavvadi. +- Added conservative Django `urls.py` route mapping for `path`, `re_path`, and legacy `url` declarations, thanks @rohitjavvadi. - Fixed provider commands with relative `--root` paths by canonicalizing explicit roots before invoking Codex or other providers. - Added first-pass Elixir Mix/Phoenix mapping for project metadata, contexts, Phoenix web slices, runtime config, Ecto migrations, project scripts, ExUnit tests, and Mix validation defaults, thanks @tears-mysthrala. - Improved `clawpatch fix` handoff context and patch-attempt changed-file auditing for dirty-worktree fixes. diff --git a/README.md b/README.md index d833584..6da095a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ validation commands and records a patch attempt under `.clawpatch/`. - C/C++ standalone `main()` files, CMake `add_executable` / `add_library` targets, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES` targets - Python project metadata, console scripts, bounded source groups, pytest suites, - and Flask/FastAPI routes + and Flask/FastAPI/Django routes - SwiftPM `Sources/*` targets and `Tests/*` suites - Laravel/PHP projects from `composer.json` and `artisan`, including routes, controllers, form requests, Artisan commands, jobs, services, models, diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 836563c..1d0e04e 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -43,7 +43,7 @@ Supported deterministic mappers today: - Go `cmd/*/main.go` - Go `internal/*` packages - Python project metadata, console scripts, root app files, bounded source groups, - pytest suites, and Flask/FastAPI routes + pytest suites, and Flask/FastAPI/Django routes - Java and Kotlin JVM semantic role groups, plus Kotlin Android semantic role groups including Hilt, Dagger, Koin, and Metro - Ruby project metadata, executables, source groups, RSpec/Minitest suites, @@ -148,11 +148,14 @@ Python mapping covers `pyproject.toml`, `setup.cfg`, `setup.py`, and `requirements.txt` metadata; `[project.scripts]`, `[tool.poetry.scripts]`, `setup.cfg` `console_scripts`, and `setup.py` console script entry points; root app files; source groups under common Python source roots including `web/`; -pytest files; Flask `@*.route(...)` handlers; and FastAPI `@*.get(...)` / -`@*.api_route(...)` handlers. Flask and FastAPI route methods are read from list, -tuple, or set literals. FastAPI paths can be positional strings or literal -`path=` keywords. Default Python command detection covers pytest, ruff, mypy, -pyright, and black. +pytest files; Flask `@*.route(...)` handlers; FastAPI `@*.get(...)` / +`@*.api_route(...)` handlers; and conservative Django `urls.py` `path(...)`, +`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 +Python command detection covers pytest, ruff, mypy, pyright, and black. Ruby mapping covers project metadata, executables, source groups, RSpec and Minitest suites, and Rails app structure. Rails legacy `config/secrets.yml`, @@ -164,7 +167,6 @@ Known gaps: - Express/Fastify/Hono route mapping is conservative and does not infer prefixes from cross-file router mounts such as `app.use("/api", router)`, `fastify.register(..., { prefix })`, or `app.route("/api", subApp)` -- no Django route mapper yet - Laravel route parsing is convention-based, does not execute Laravel route discovery, and may omit prefixes applied by `Route::group(...)` wrappers - C#/.NET mapping does not evaluate MSBuild conditions, imported props/targets, diff --git a/docs/index.md b/docs/index.md index c7ec86e..6e7f64a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ stderr so pipes stay parseable. ## What clawpatch does -- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, C#/.NET projects and ASP.NET endpoints, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units. +- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI/Django routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, C#/.NET projects and ASP.NET endpoints, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units. - **Automated code review.** Reviews features with AI providers (Codex CLI today), persists findings with severity, category, and line locations. - **Explicit fix workflow.** `clawpatch fix` runs validated patches for one finding at a time, never commits or pushes automatically. - **Stable state model.** All features, findings, patches live in `.clawpatch/` as JSON, resumable across runs. diff --git a/docs/quickstart.md b/docs/quickstart.md index 82e36f8..11142f1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -48,8 +48,8 @@ This discovers reviewable features: - Next.js routes - Go packages and commands - Java/Kotlin Gradle modules +- Python packages, console scripts, Flask/FastAPI/Django routes, and pytest suites - C#/.NET projects, ASP.NET endpoints, source groups, and test projects -- Python packages, console scripts, Flask/FastAPI routes, and pytest suites - JVM semantic role groups - Ruby packages, Rails apps, executables, and tests - Rust crates and binaries diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9c2a93c..39c0141 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11600,6 +11600,185 @@ add_executable(headerapp include/headers.hpp) expect(result.features.some((feature) => feature.source === "python-flask-route")).toBe(false); }); + it("maps Django urls.py routes conservatively", async () => { + 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, + "mysite/urls.py", + [ + "from django.conf.urls import url", + "from django.urls import include, path, re_path", + "from django.contrib import admin", + "from . import views", + "from .views import SignupView", + "", + '"""', + "urlpatterns = [", + " path('docs-only/', views.docs_only),", + "]", + '"""', + "", + 'r"""', + "urlpatterns = [", + " path('raw-docs-only/', views.raw_docs_only),", + "]", + '"""', + "", + "def build_local_patterns():", + " '''", + " urlpatterns = [", + " path('indented-docs-only/', views.indented_docs_only),", + " ]", + " '''", + " urlpatterns = [", + " path('local-only/', views.local_only),", + " ]", + " return urlpatterns", + "", + "def helper_patterns():", + " return [", + " path('helper/', views.helper),", + " ]", + "", + "unused_patterns = [", + " path('unused/', views.unused),", + "]", + "", + "urlpatterns = [path('inline/', views.inline), re_path(r'^inline-regex/$', views.inline_regex),", + " path('', views.index, name='index'),", + " path('users//', views.user_detail, name='user-detail'),", + " path('accounts/password/reset/', views.password_reset, name='password-reset'),", + " path('orders/', views.orders, name='orders'),", + " path(", + " 'reports/',", + " views.reports,", + " name='reports',", + " ),", + " path('signup/', SignupView.as_view(), name='signup'),", + " path('admin/', admin.site.urls),", + " path('api/', 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),", + " path(DYNAMIC_ROUTE, views.dynamic),", + " path(f'tenant/{slug}/', views.dynamic),", + " re_path(r'^(foo|bar)/$', views.complex_regex),", + " custom_path('custom/', views.custom),", + " # path('commented/', views.commented),", + " \"path('string/', views.string)\",", + "]", + "urlpatterns += [path('extra/', views.extra)]", + "", + ].join("\n"), + ); + await writeFixture(root, "fallback/__init__.py", ""); + await writeFixture( + root, + "fallback/urls.py", + [ + "from . import views", + "", + "urlpatterns = [", + " path('dependency-only/', views.dependency_only),", + "]", + "", + ].join("\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, "mysite/test_urls.py", "def test_urls():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const routes = result.features.filter((feature) => feature.source === "python-django-route"); + const titles = routes.map((feature) => feature.title); + const byTitle = (title: string) => routes.find((feature) => feature.title === title); + + expect(project.detected.frameworks).toContain("django"); + expect(titles).toEqual( + expect.arrayContaining([ + "Django route /", + "Django route /users/:pk/", + "Django route /accounts/password/reset/", + "Django route /orders/", + "Django route /reports/", + "Django route /signup/", + "Django route /admin/", + "Django route /api/", + "Django route /tuple-api/", + "Django route /dependency-only/", + "Django route /legacy/:slug/", + "Django route /old/:pk/", + "Django route /inline/", + "Django route /inline-regex/", + "Django route /extra/", + ]), + ); + expect(byTitle("Django route /")?.entrypoints[0]).toMatchObject({ + path: "mysite/urls.py", + symbol: "views.index", + route: "/", + }); + expect(byTitle("Django route /")?.tests).toEqual([ + { path: "mysite/test_urls.py", command: "pytest" }, + ]); + expect(byTitle("Django route /api/")?.entrypoints[0]?.symbol).toBe("api.urls"); + 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"); + expect(byTitle("Django route /dependency-only/")?.entrypoints[0]).toMatchObject({ + path: "fallback/urls.py", + symbol: "views.dependency_only", + route: "/dependency-only/", + }); + expect(byTitle("Django route /accounts/password/reset/")?.trustBoundaries).toContain("auth"); + expect(byTitle("Django route /signup/")?.trustBoundaries).toContain("auth"); + 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/"); + expect(titles).not.toContain("Django route /custom/"); + expect(titles).not.toContain("Django route /commented/"); + expect(titles).not.toContain("Django route /string/"); + expect(titles).not.toContain("Django route /(foo|bar)/"); + expect(titles).not.toContain("Django route /docs-only/"); + expect(titles).not.toContain("Django route /raw-docs-only/"); + 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 /unused/"); + }); + + it("does not map Django-shaped URLs without a Django signal", async () => { + const root = await fixtureRoot("clawpatch-python-django-url-false-positive-"); + await writeFixture(root, "requirements.txt", "pytest\n"); + await writeFixture(root, "web/__init__.py", ""); + await writeFixture( + root, + "web/urls.py", + [ + 'r"""from django.urls import path"""', + "", + "def path(route, handler):", + " return (route, handler)", + "", + "urlpatterns = [", + " path('not-django/', handler),", + "]", + "def handler():", + " pass", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(project.detected.frameworks).not.toContain("django"); + expect(result.features.some((feature) => feature.source === "python-django-route")).toBe(false); + }); + it("maps FastAPI routes in root and web source files", async () => { const root = await fixtureRoot("clawpatch-python-fastapi-routes-"); await writeFixture(root, "requirements.txt", "fastapi\npytest\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index d173c29..730c70b 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -34,6 +34,13 @@ type FastApiRoute = { methods: string[]; }; +type DjangoRoute = { + filePath: string; + routePath: string; + symbol: string | null; + include: boolean; +}; + type PyprojectInfo = { name: string | null; scripts: PythonScript[]; @@ -139,6 +146,10 @@ export async function pythonSeeds(root: string): Promise { seeds.push(route); } + for (const route of await djangoRouteSeeds(root, testFiles, testCommand)) { + seeds.push(route); + } + for (const group of await pythonSourceGroups(root)) { const tests = associatedTests(group.files, testFiles, testCommand); seeds.push({ @@ -375,6 +386,548 @@ async function resolvePythonScript( return { entryPath: metadataPath, symbol }; } +async function djangoRouteSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + const hasDjangoDependency = await pythonDependencyHas(root, "django"); + const routeFiles = uniquePaths([ + ...(await rootPythonSourceFiles(root)), + ...(await walk(root, await pythonSourceRoots(root))).filter(isReviewablePythonSourceFile), + ]); + const seeds: FeatureSeed[] = []; + 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, + }); + } + } + return seeds; +} + +function sourceLooksDjangoUrls( + filePath: string, + source: string, + hasDjangoDependency: boolean, +): boolean { + if (!/(^|\/)urls\.py$/u.test(filePath) || djangoUrlpatternsAssignments(source).length === 0) { + return false; + } + if (sourceLooksDjangoUrlsImport(source)) { + return true; + } + return hasDjangoDependency; +} + +function sourceLooksDjangoUrlsImport(source: string): boolean { + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + const stringEnd = pythonStringEnd(source, index); + if (stringEnd !== null) { + index = stringEnd; + continue; + } + if (char === "#") { + index = pythonCommentEnd(source, index); + continue; + } + if (!isPythonLineStart(source, index)) { + continue; + } + const lineEnd = source.indexOf("\n", index); + const rawLine = source.slice(index, lineEnd === -1 ? source.length : lineEnd); + if ( + /^(?:from\s+django\.(?:urls|conf\.urls)\s+import\s+|import\s+django\.(?:urls|conf\.urls)\b)/u.test( + rawLine, + ) + ) { + return true; + } + } + return false; +} + +function parseDjangoRoutes(filePath: string, source: string): DjangoRoute[] { + const routes: DjangoRoute[] = []; + for (const call of djangoRouteCalls(source)) { + const route = parseDjangoRouteCall(filePath, call); + if (route !== null) { + routes.push(route); + } + } + return uniqueDjangoRoutes(routes); +} + +function djangoRouteCalls(source: string): string[] { + return djangoUrlpatternsBodies(source).flatMap(djangoRouteCallsInUrlpatterns); +} + +function djangoUrlpatternsBodies(source: string): string[] { + const bodies: string[] = []; + for (const assignment of djangoUrlpatternsAssignments(source)) { + const valueIndex = nextPythonValueIndex(source, assignment.valueStart); + if (source[valueIndex] !== "[") { + continue; + } + const end = matchingPythonBracketEnd(source, valueIndex); + if (end !== null) { + bodies.push(source.slice(valueIndex + 1, end)); + } + } + return bodies; +} + +function djangoUrlpatternsAssignments(source: string): Array<{ valueStart: number }> { + const assignments: Array<{ valueStart: number }> = []; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + const stringEnd = pythonStringEnd(source, index); + if (stringEnd !== null) { + index = stringEnd; + continue; + } + if (char === "#") { + index = pythonCommentEnd(source, index); + continue; + } + if (!isPythonLineStart(source, index)) { + continue; + } + if (!source.startsWith("urlpatterns", index)) { + continue; + } + const lineEnd = source.indexOf("\n", index); + const rawLine = source.slice(index, lineEnd === -1 ? source.length : lineEnd); + const match = /^urlpatterns\s*(?::[^=\n]+)?\s*(\+?=)/u.exec(rawLine); + if (match?.[0] !== undefined) { + assignments.push({ valueStart: index + match[0].length }); + } + } + return assignments; +} + +function isPythonLineStart(source: string, index: number): boolean { + return index === 0 || source[index - 1] === "\n"; +} + +function nextPythonValueIndex(source: string, index: number): number { + let current = index; + while (current < source.length) { + const char = source[current]; + if (char === "#") { + const newline = source.indexOf("\n", current + 1); + current = newline === -1 ? source.length : newline + 1; + continue; + } + if (char === " " || char === "\t" || char === "\r" || char === "\n") { + current += 1; + continue; + } + break; + } + return current; +} + +function matchingPythonBracketEnd(source: string, start: number): number | null { + let depth = 0; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + const stringEnd = pythonStringEnd(source, index); + if (stringEnd !== null) { + index = stringEnd; + continue; + } + if (char === "#") { + index = pythonCommentEnd(source, index); + continue; + } + if (char === "[") { + depth += 1; + continue; + } + if (char === "]") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return null; +} + +function djangoRouteCallsInUrlpatterns(source: string): string[] { + const calls: string[] = []; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + const stringEnd = pythonStringEnd(source, index); + if (stringEnd !== null) { + index = stringEnd; + continue; + } + if (char === "#") { + index = pythonCommentEnd(source, index); + continue; + } + if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + const call = djangoRouteCallAt(source, index); + if (call !== null) { + calls.push(call.source); + index = call.end; + continue; + } + } + if (char === "(") { + parenDepth += 1; + } else if (char === ")" && parenDepth > 0) { + parenDepth -= 1; + } else if (char === "[") { + bracketDepth += 1; + } else if (char === "]" && bracketDepth > 0) { + bracketDepth -= 1; + } else if (char === "{") { + braceDepth += 1; + } else if (char === "}" && braceDepth > 0) { + braceDepth -= 1; + } + } + return calls; +} + +function djangoRouteCallAt(source: string, index: number): { source: string; end: number } | null { + const helper = djangoRouteHelperAt(source, index); + if (helper === null) { + return null; + } + let parenIndex = index + helper.length; + while (source[parenIndex] === " " || source[parenIndex] === "\t") { + parenIndex += 1; + } + if (source[parenIndex] !== "(") { + return null; + } + const end = matchingPythonParenEnd(source, parenIndex); + return end === null ? null : { source: source.slice(index, end + 1), end }; +} + +function djangoRouteHelperAt(source: string, index: number): string | null { + const previous = source[index - 1]; + if (previous !== undefined && /[A-Za-z0-9_.]/u.test(previous)) { + return null; + } + for (const helper of ["re_path", "path", "url"]) { + if (!source.startsWith(helper, index)) { + continue; + } + const next = source[index + helper.length]; + if (next === "(" || next === " " || next === "\t") { + return helper; + } + } + return null; +} + +function matchingPythonParenEnd(source: string, start: number): number | null { + let depth = 0; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + const stringEnd = pythonStringEnd(source, index); + if (stringEnd !== null) { + index = stringEnd; + continue; + } + if (char === "#") { + index = pythonCommentEnd(source, index); + continue; + } + if (char === "(") { + depth += 1; + continue; + } + if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return null; +} + +function pythonStringEnd(source: string, start: number): number | null { + const quoteStart = pythonStringQuoteStart(source, start); + if (quoteStart === null) { + return null; + } + const quote = source[quoteStart]; + if (quote !== '"' && quote !== "'") { + return null; + } + const triple = source.startsWith(quote.repeat(3), quoteStart); + const endQuote = triple ? quote.repeat(3) : quote; + let escaped = false; + for (let index = quoteStart + endQuote.length; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + if (triple) { + if (source.startsWith(endQuote, index)) { + return index + endQuote.length - 1; + } + continue; + } + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + return index; + } + } + return source.length - 1; +} + +function pythonStringQuoteStart(source: string, start: number): number | null { + const char = source[start]; + if (char === '"' || char === "'") { + return start; + } + if (char === undefined || !/[rRuUbBfF]/u.test(char)) { + return null; + } + let index = start; + while (/[rRuUbBfF]/u.test(source[index] ?? "")) { + index += 1; + } + const quote = source[index]; + if (quote !== '"' && quote !== "'") { + return null; + } + const prefix = source.slice(start, index).toLowerCase(); + return /^[rubf]+$/u.test(prefix) ? index : null; +} + +function pythonCommentEnd(source: string, start: number): number { + const newline = source.indexOf("\n", start + 1); + return newline === -1 ? source.length - 1 : newline; +} + +function parseDjangoRouteCall(filePath: string, call: string): DjangoRoute | null { + const match = /^\s*(path|re_path|url)\s*\(([\s\S]*)\)\s*,?\s*(?:#.*)?$/u.exec(call); + const helper = match?.[1]; + const args = match?.[2]; + if (helper === undefined || args === undefined) { + return null; + } + const parts = splitTopLevelPythonArgs(args); + const rawRoute = pythonStringLiteralValue(parts[0] ?? ""); + if (rawRoute === null) { + return null; + } + const routePath = + helper === "path" ? normalizeDjangoPathRoute(rawRoute) : normalizeDjangoRegexRoute(rawRoute); + if (routePath === null) { + return null; + } + const target = (parts[1] ?? "").trim(); + const include = target.startsWith("include("); + return { + filePath, + routePath, + symbol: include ? djangoIncludeSymbol(target) : djangoHandlerSymbol(target), + include, + }; +} + +function splitTopLevelPythonArgs(source: string): string[] { + const args: string[] = []; + let start = 0; + let quote: string | null = null; + let escaped = false; + let depth = 0; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (char === "(" || char === "[" || char === "{") { + depth += 1; + } else if (char === ")" || char === "]" || char === "}") { + depth -= 1; + } else if (char === "," && depth === 0) { + args.push(source.slice(start, index).trim()); + start = index + 1; + } + } + args.push(source.slice(start).trim()); + return args; +} + +function pythonStringLiteralValue(source: string): string | null { + const match = /^\s*([rRuUbBfF]*)(["'])(.*?)\2\s*$/u.exec(source); + const prefix = match?.[1] ?? ""; + const value = match?.[3]; + if (value === undefined || /f/iu.test(prefix)) { + return null; + } + if (/r/iu.test(prefix)) { + return value; + } + return unescapePythonString(value); +} + +function unescapePythonString(value: string): string { + let output = ""; + let escaped = false; + for (const char of value) { + if (escaped) { + output += char; + escaped = false; + } else if (char === "\\") { + escaped = true; + } else { + output += char; + } + } + return escaped ? `${output}\\` : output; +} + +function normalizeDjangoPathRoute(route: string): string | null { + const converted = route.replace( + /<(?:(?:[A-Za-z_][A-Za-z0-9_]*):)?([A-Za-z_][A-Za-z0-9_]*)>/gu, + ":$1", + ); + if (/[<>]/u.test(converted)) { + return null; + } + return normalizeDjangoRoutePath(converted); +} + +function normalizeDjangoRegexRoute(route: string): string | null { + let converted = route.replace(/^\^/u, "").replace(/\$$/u, ""); + if (/\(\?(?:[=!<]|:)/u.test(converted) || /\|/u.test(converted)) { + return null; + } + converted = converted.replace(/\(\?P<([A-Za-z_][A-Za-z0-9_]*)>[^)]+\)/gu, ":$1"); + if (/[()[\]{}+*?]/u.test(converted) || /\\(?!\/)/u.test(converted)) { + return null; + } + return normalizeDjangoRoutePath(converted.replace(/\\\//gu, "/")); +} + +function normalizeDjangoRoutePath(route: string): string { + const trimmed = route.replace(/^\/+/u, ""); + return trimmed.length === 0 ? "/" : `/${trimmed}`; +} + +function djangoIncludeSymbol(target: string): string | null { + const match = /^include\s*\(([\s\S]*)\)\s*$/u.exec(target); + const args = match?.[1]; + if (args === undefined) { + return null; + } + return pythonStringLiteralValue(splitTopLevelPythonArgs(args)[0] ?? ""); +} + +function djangoHandlerSymbol(target: string): string | null { + const viewCall = + /^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.as_view\s*\(\s*\)\s*$/u.exec( + target, + )?.[1]; + if (viewCall !== undefined) { + return `${viewCall}.as_view`; + } + return /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u.test(target) ? target : null; +} + +function djangoRouteSummary(route: DjangoRoute): string { + if (route.include) { + return `Django URL include ${route.routePath} declared in ${route.filePath}.`; + } + if (route.symbol !== null) { + return `Django route ${route.routePath} handled by ${route.symbol} in ${route.filePath}.`; + } + return `Django route ${route.routePath} declared in ${route.filePath}.`; +} + +function djangoRouteTrustBoundaries(route: DjangoRoute): FeatureSeed["trustBoundaries"] { + const boundaries: FeatureSeed["trustBoundaries"] = ["network", "user-input", "serialization"]; + if ( + /(^|\/)(admin|auth|login|logout|token|session|user|account|password|register|signup)(\/|$)/iu.test( + route.routePath, + ) + ) { + boundaries.push("auth"); + } + return boundaries; +} + +function uniqueDjangoRoutes(routes: DjangoRoute[]): DjangoRoute[] { + const seen = new Set(); + const output: DjangoRoute[] = []; + for (const route of routes) { + const key = `${route.filePath}:${route.routePath}:${route.symbol ?? ""}:${String(route.include)}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(route); + } + return output; +} + async function fastApiRouteSeeds( root: string, testFiles: string[],