From 4dc7623dad9e9e810f823424baf1a3bd78480c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yishun=20Tang=20=C2=B7=20CrazyAnt?= Date: Thu, 3 Sep 2026 16:22:59 +0800 Subject: [PATCH] fix: name a route after its own decorator, not the file's first The Python adapter looked a route's path up with `file.calls.find` by the decorator's dotted name, so every `@app.post` in a module resolved to the first one. A real Flask module with 57 routes produced 55 nodes carrying 4 distinct names, and `documentedCapabilityLabel` then matched 18 unrelated handlers to one capability. One route per file hid it: the file-wide lookup and the per-declaration lookup return the same call. The extractor already records each decorator call inside the scope of the function it decorates, on a line above the `def`, so a route now reads the nearest such call above its own declaration. `enclosingClass` is not part of the match: a route declared inside a method carries the class on the call but not on the declaration, so comparing the two loses the path instead of sharpening it, while the nearest-above rule already separates same-named methods in different classes. The existing fixtures could not catch this, so the two new tests cover two same-method routes in one module and the scope cases that pin the tie-break. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++ adapters/python/src/index.ts | 32 +++++++++++-- tests/python-adapter.test.ts | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d24d5e..005782b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes are documented here. +## Unreleased + +### Fixed + +- The Python adapter labeled every same-method route in a module with the + first decorator's path, because it looked the path up by the decorator's + dotted name across the whole file. Each route now reads the decorator call + that sits above its own declaration, so a module with 57 routes stops + collapsing onto four names and capability matching runs on the real paths. + ## 0.9.0 - 2026-09-03 ### Added diff --git a/adapters/python/src/index.ts b/adapters/python/src/index.ts index cfb6e03..bab785d 100644 --- a/adapters/python/src/index.ts +++ b/adapters/python/src/index.ts @@ -24,7 +24,7 @@ import { stableId, templateVariables, } from "@agent-runtime-map/analysis-kit"; -import type { PythonCall, PythonFacts, PythonFile } from "./facts.js"; +import type { PythonCall, PythonFacts, PythonFile, PythonFunction } from "./facts.js"; const SOURCE_EXTENSIONS = new Set([".py"]); const EXCLUDED_FILE_PATTERN = /(^test_.*\.py$|_test\.py$|^conftest\.py$|\.pyi$)/i; @@ -157,7 +157,7 @@ function collectFile( } for (const declaration of file.functions) { - const route = routeFromDecorators(declaration.decorators, file); + const route = routeFromDecorators(declaration, file); const classification = classifyDeclaration({ relativeFile, name: declaration.name, @@ -441,19 +441,41 @@ interface RouteConvention { decorator: string; } -function routeFromDecorators(decorators: string[], file: PythonFile): RouteConvention | undefined { - for (const decorator of decorators) { +function routeFromDecorators(declaration: PythonFunction, file: PythonFile): RouteConvention | undefined { + for (const decorator of declaration.decorators) { const match = ROUTE_DECORATOR.exec(decorator); if (!match) continue; const owner = match[1]!; const method = match[2]!.toUpperCase(); - const call = file.calls.find((item) => item.callee === decorator && item.stringArguments.length); + const call = decoratorCall(decorator, declaration, file); const routePath = call?.stringArguments[0] ?? "/"; return { method, path: routePath, framework: frameworkForOwner(owner, file), decorator }; } return undefined; } +/** + * The path a decorator registers belongs to the declaration it sits above. Searching + * the file's calls by dotted name alone returns the first `app.post(...)` in the + * module, which labels every same-method route in a file with the first one's path. + * The extractor records a decorator call inside the scope of the function it + * decorates, on a line above the `def`, so the nearest such call above the + * declaration is this route's own registration. `enclosingClass` is deliberately not + * compared: a route defined inside a method carries the class on the call but not on + * the declaration, and the nearest-above rule already separates same-named methods. + */ +function decoratorCall(decorator: string, declaration: PythonFunction, file: PythonFile): PythonCall | undefined { + let nearest: PythonCall | undefined; + for (const item of file.calls) { + if (item.callee !== decorator) continue; + if (item.stringArguments.length === 0) continue; + if (item.enclosingFunction !== declaration.name) continue; + if (item.line > declaration.line) continue; + if (!nearest || item.line > nearest.line) nearest = item; + } + return nearest; +} + function frameworkForOwner(owner: string, file: PythonFile): string { const built = file.assignments.find((item) => item.name === owner && item.factory && ROUTE_APP_FACTORIES.test(item.factory)); if (!built?.factory) return "python_http"; diff --git a/tests/python-adapter.test.ts b/tests/python-adapter.test.ts index 4f31877..e044a1b 100644 --- a/tests/python-adapter.test.ts +++ b/tests/python-adapter.test.ts @@ -71,6 +71,97 @@ describe("Python adapter", () => { expect(python.method).toBe(typescript.method); }); + it("labels every route in a module with its own decorator path", async () => { + // One route per file hides this: the file-wide lookup and the per-declaration + // lookup return the same call. It takes two same-method routes to show up. + const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-routes-")); + temporaryDirectories.push(root); + await writeFile(path.join(root, "app.py"), [ + "from flask import Flask", + "", + "app = Flask(__name__)", + "", + "@app.get(\"/health\")", + "def health():", + " return {\"ok\": True}", + "", + "@app.post(\"/api/first\")", + "def first_handler():", + " return {\"n\": 1}", + "", + "@app.post(\"/api/second\")", + "def second_handler():", + " return {\"n\": 2}", + "", + "@app.post(\"/api/third\")", + "def third_handler():", + " return {\"n\": 3}", + "", + ].join("\n")); + + const raw = await analyzePythonProject(root); + const routes = new Map(raw.nodes + .filter((node) => node.kind === "route") + .map((node) => [node.qualifiedName, node])); + + expect(routes.get("app.py#health")?.metadata?.path).toBe("/health"); + expect(routes.get("app.py#first_handler")?.metadata?.path).toBe("/api/first"); + expect(routes.get("app.py#second_handler")?.metadata?.path).toBe("/api/second"); + expect(routes.get("app.py#third_handler")?.metadata?.path).toBe("/api/third"); + // The node name is what the Viewer and the capability matcher read, so it has to + // carry the same path, and four endpoints have to read as four endpoints. + expect(new Set([...routes.values()].map((node) => node.name)).size).toBe(4); + expect(routes.get("app.py#third_handler")?.name).toBe("POST /api/third"); + }); + + it("picks the decorator above the declaration, not the first one that matches", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-route-scopes-")); + temporaryDirectories.push(root); + await writeFile(path.join(root, "app.py"), [ + "from flask import Flask", + "", + "app = Flask(__name__)", + "", + "class Orders:", + " @app.get(\"/api/orders\")", + " def handle(self):", + " return []", + "", + "class Invoices:", + " @app.get(\"/api/invoices\")", + " def handle(self):", + " return []", + "", + "def register():", + " @app.get(\"/api/late\")", + " def late_handler():", + " return []", + "", + " return late_handler", + "", + "class Wiring:", + " def wire(self):", + " @app.get(\"/api/wired\")", + " def wired_handler():", + " return []", + "", + " return wired_handler", + "", + ].join("\n")); + + const raw = await analyzePythonProject(root); + const paths = raw.nodes + .filter((node) => node.kind === "route") + .map((node) => node.metadata?.path); + + // Two methods share a name, so the enclosing scope cannot be the only tie-break. + // The last two are routes declared inside another declaration: the extractor puts + // the class on the decorator call but not on the nested declaration, so comparing + // the two would lose the path rather than sharpen it. + expect(paths).toEqual(expect.arrayContaining(["/api/orders", "/api/invoices", "/api/late", "/api/wired"])); + expect(paths).not.toContain("/"); + }); + it("reports a file the interpreter cannot parse instead of guessing at it", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-broken-")); temporaryDirectories.push(root);