diff --git a/src/core/analyzer.ts b/src/core/analyzer.ts index 9e1f927..3390b15 100644 --- a/src/core/analyzer.ts +++ b/src/core/analyzer.ts @@ -25,9 +25,12 @@ function resolveVariables( path: string, variables: Map, ): string { + // Match sentinel-wrapped names produced by extractPathFromNode for identifiers. + // Using \uE000 (Unicode private use) as sentinel ensures FastAPI path parameters + // like {id} are never substituted — only actual identifier references are resolved. return path.replace( - /\{([^}]+)\}/g, - (match, name) => variables.get(name) ?? match, + /\uE000([^\uE000]+)\uE000/g, + (_, name) => variables.get(name) ?? `{${name}}`, ) } diff --git a/src/core/extractors.ts b/src/core/extractors.ts index 4fa8252..f269c9b 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -63,7 +63,7 @@ function collectNodesByType(node: Node, type: string, results: Node[]): void { /** * Collects string variable assignments from the AST for path resolution. - * Only resolves simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`). + * Handles simple assignments like `WEBHOOK_PATH = "/webhook"`. * * Examples: * WEBHOOK_PATH = "/webhook" -> Map { "WEBHOOK_PATH" => "/webhook" } @@ -140,11 +140,13 @@ export function extractPathFromNode(node: Node): string { return extractPathFromNode(left) + extractPathFromNode(right) } // For other operators, just return the raw text - return `{${node.text}}` + return `\uE000${node.text}\uE000` } default: - // Dynamic values: variable, attribute access, or function call - return `{${node.text}}` + // Dynamic values: variable, attribute access, or function call. + // Use \uE000 (Unicode private use) as sentinel so resolveVariables can + // distinguish these from FastAPI path parameters like {id}. + return `\uE000${node.text}\uE000` } } diff --git a/src/test/core/analyzer.test.ts b/src/test/core/analyzer.test.ts index 16ad751..df572be 100644 --- a/src/test/core/analyzer.test.ts +++ b/src/test/core/analyzer.test.ts @@ -182,6 +182,35 @@ app.include_router(users.router, prefix=USERS_PREFIX) assert.strictEqual(result.includeRouters[0].prefix, "/users") }) + test("does not substitute function-local variables into URL path parameters", () => { + const code = ` +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/integrations/{integration}/authorize") +def initiate_oauth_flow(integration: str): + integration = "redis" + pass + +@router.get("/integrations/{integration}/callback") +def handle_callback(integration: str): + pass +` + const tree = parse(code) + const result = analyzeTree(tree, "/test/file.py") + + assert.strictEqual(result.routes.length, 2) + assert.strictEqual( + result.routes[0].path, + "/integrations/{integration}/authorize", + ) + assert.strictEqual( + result.routes[1].path, + "/integrations/{integration}/callback", + ) + }) + test("sets filePath correctly", () => { const code = "x = 1" const tree = parse(code) diff --git a/src/test/core/extractors.test.ts b/src/test/core/extractors.test.ts index 00d6212..bdce044 100644 --- a/src/test/core/extractors.test.ts +++ b/src/test/core/extractors.test.ts @@ -121,7 +121,7 @@ def handler(): const result = decoratorExtractor(decoratedDefs[0]) assert.ok(result) - assert.strictEqual(result.path, "{BASE_PATH}") + assert.strictEqual(result.path, "\uE000BASE_PATH\uE000") }) test("handles dynamic path with attribute", () => { @@ -138,7 +138,7 @@ def handler(): const result = decoratorExtractor(decoratedDefs[0]) assert.ok(result) - assert.strictEqual(result.path, "{settings.API_PREFIX}") + assert.strictEqual(result.path, "\uE000settings.API_PREFIX\uE000") }) test("handles path concatenation", () => { @@ -155,7 +155,7 @@ def handler(): const result = decoratorExtractor(decoratedDefs[0]) assert.ok(result) - assert.strictEqual(result.path, "{BASE}/users") + assert.strictEqual(result.path, "\uE000BASE\uE000/users") }) test("returns null for simple decorator without call", () => { @@ -405,7 +405,7 @@ def list_users(): const result = routerExtractor(assignments[0]) assert.ok(result) - assert.strictEqual(result.prefix, "{settings.API_PREFIX}") + assert.strictEqual(result.prefix, "\uE000settings.API_PREFIX\uE000") }) test("returns null for non-router assignment", () => { @@ -563,7 +563,7 @@ def list_users(): const result = includeRouterExtractor(calls[0]) assert.ok(result) - assert.strictEqual(result.prefix, "{settings.PREFIX}") + assert.strictEqual(result.prefix, "\uE000settings.PREFIX\uE000") }) test("extracts include_router with tags", () => { @@ -615,7 +615,7 @@ def list_users(): const result = mountExtractor(calls[0]) assert.ok(result) - assert.strictEqual(result.path, "{settings.STATIC_PATH}") + assert.strictEqual(result.path, "\uE000settings.STATIC_PATH\uE000") }) test("returns null for non-mount call", () => { @@ -652,7 +652,7 @@ def list_users(): const tree = parse(code) const ops = findNodesByType(tree.rootNode, "binary_operator") const result = extractPathFromNode(ops[0]) - assert.strictEqual(result, "{a - b}") + assert.strictEqual(result, "\uE000a - b\uE000") }) }) @@ -688,7 +688,7 @@ def handler(): const result = decoratorExtractor(decoratedDefs[0]) assert.ok(result) - assert.strictEqual(result.path, "{get_path()}") + assert.strictEqual(result.path, "\uE000get_path()\uE000") }) }) })