From 16dacf2cc95786415fc56fc21a69ebfc1c4eeb76 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 25 Feb 2026 11:44:23 -0800 Subject: [PATCH 1/3] Avoid using string assignments from within functions --- src/core/extractors.ts | 28 +++++++++++++++++++++++++++- src/test/core/analyzer.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/core/extractors.ts b/src/core/extractors.ts index 4fa8252..aea470d 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -61,9 +61,30 @@ function collectNodesByType(node: Node, type: string, results: Node[]): void { } } +/** + * Returns true if the node is nested inside a function, class, or lambda body. + * Used to restrict variable collection to module-level scope. + */ +function isInsideFunctionOrClass(node: Node): boolean { + let current = node.parent + while (current !== null) { + if ( + current.type === "function_definition" || + current.type === "class_definition" || + current.type === "lambda" + ) { + return true + } + current = current.parent + } + return false +} + /** * Collects string variable assignments from the AST for path resolution. - * Only resolves simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`). + * Only resolves module-level simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`). + * Skips assignments inside functions or classes to avoid substituting URL path + * parameters with local variable values that happen to share a name. * * Examples: * WEBHOOK_PATH = "/webhook" -> Map { "WEBHOOK_PATH" => "/webhook" } @@ -75,6 +96,11 @@ export function collectStringVariables(rootNode: Node): Map { const assignmentNodes = findNodesByType(rootNode, "assignment") for (const assign of assignmentNodes) { + // Skip assignments inside function/class bodies to prevent local variable + // names (e.g. `integration = "redis"`) from replacing URL path parameters + // with the same name (e.g. `{integration}`). + if (isInsideFunctionOrClass(assign)) continue + const left = assign.childForFieldName("left") const right = assign.childForFieldName("right") if ( 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) From 9e32da4294c5af62bc145eee665d369150f5ed8c Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 25 Feb 2026 11:56:17 -0800 Subject: [PATCH 2/3] Use sentinel value for diffentiating --- src/core/analyzer.ts | 7 +++++-- src/core/extractors.ts | 8 +++++--- src/test/core/extractors.test.ts | 16 ++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) 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 aea470d..5c8f2dc 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -166,11 +166,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/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") }) }) }) From 6cec7b9e6c64c5d75c36959065151fccb5bf75c3 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 25 Feb 2026 11:58:23 -0800 Subject: [PATCH 3/3] Remove unused function --- src/core/extractors.ts | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/core/extractors.ts b/src/core/extractors.ts index 5c8f2dc..f269c9b 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -61,30 +61,9 @@ function collectNodesByType(node: Node, type: string, results: Node[]): void { } } -/** - * Returns true if the node is nested inside a function, class, or lambda body. - * Used to restrict variable collection to module-level scope. - */ -function isInsideFunctionOrClass(node: Node): boolean { - let current = node.parent - while (current !== null) { - if ( - current.type === "function_definition" || - current.type === "class_definition" || - current.type === "lambda" - ) { - return true - } - current = current.parent - } - return false -} - /** * Collects string variable assignments from the AST for path resolution. - * Only resolves module-level simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`). - * Skips assignments inside functions or classes to avoid substituting URL path - * parameters with local variable values that happen to share a name. + * Handles simple assignments like `WEBHOOK_PATH = "/webhook"`. * * Examples: * WEBHOOK_PATH = "/webhook" -> Map { "WEBHOOK_PATH" => "/webhook" } @@ -96,11 +75,6 @@ export function collectStringVariables(rootNode: Node): Map { const assignmentNodes = findNodesByType(rootNode, "assignment") for (const assign of assignmentNodes) { - // Skip assignments inside function/class bodies to prevent local variable - // names (e.g. `integration = "redis"`) from replacing URL path parameters - // with the same name (e.g. `{integration}`). - if (isInsideFunctionOrClass(assign)) continue - const left = assign.childForFieldName("left") const right = assign.childForFieldName("right") if (