Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/core/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ function resolveVariables(
path: string,
variables: Map<string, string>,
): 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}}`,
)
}

Expand Down
10 changes: 6 additions & 4 deletions src/core/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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`
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/test/core/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions src/test/core/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")
})
})

Expand Down Expand Up @@ -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")
})
})
})
Loading