From 5ddd5dcc788a8f249be866f1e156e9abc7349f82 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Tue, 8 Sep 2026 22:16:34 +0530 Subject: [PATCH] feat(graph): add Flask route resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect decorator-defined Flask routes to their handler functions. The resolver recognizes @app.route() with and without an explicit methods list (GET is the default), shortcut decorators, and the same decorators on Blueprint instances, emitting one stable route node per explicitly declared HTTP method. Flask path converters such as /users/ are preserved verbatim — they are the route's identity, and normalizing them would collide distinct routes. Detection keys on a staged Python module actually importing flask rather than dependency manifests: the detection context sees only staged corpus files, and manifests are not staged. An import is the reliable observable and 'flask_restful' does not match. Same-file handlers resolve only when unambiguous; missing, cross-file, and duplicate handlers stay unresolved. No identity, reconciliation, schema, or drift-semantics changes. Resolves #112 --- CHANGELOG.md | 1 + src/graph/__tests__/fixtures/flask-app.py | 43 ++++ .../resolver-flask-integration.test.ts | 60 ++++++ src/graph/__tests__/resolver-flask.test.ts | 158 +++++++++++++++ src/graph/resolution/frameworks/flask.ts | 183 ++++++++++++++++++ src/graph/resolution/frameworks/index.ts | 4 +- 6 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 src/graph/__tests__/fixtures/flask-app.py create mode 100644 src/graph/__tests__/resolver-flask-integration.test.ts create mode 100644 src/graph/__tests__/resolver-flask.test.ts create mode 100644 src/graph/resolution/frameworks/flask.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 912f7f20..dd8beacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. ### Added - `mex telemetry disable` and `mex telemetry enable`, writing the same `~/.mex/config.json` key as `mex config set telemetry on|off`. `mex telemetry --help` and `mex telemetry status` now name the `DO_NOT_TRACK=1` and `MEX_TELEMETRY=0` env opt-outs and say which one is in effect; previously the only switch lived under `config` and the env vars appeared solely in the first-run notice (#110). +- A bounded Flask framework resolver connecting `@app.route()` and shortcut decorators (`@app.get()`, `@app.post()`, and their Blueprint equivalents) to their handler functions. One stable route node is emitted per explicitly declared HTTP method with Flask path converters preserved verbatim, same-file handlers resolve only when unambiguous, and detection keys on a staged Python module actually importing flask — the reliable observable, since dependency manifests are not staged corpus files (#112). ## [0.8.0] - 2026-09-02 diff --git a/src/graph/__tests__/fixtures/flask-app.py b/src/graph/__tests__/fixtures/flask-app.py new file mode 100644 index 00000000..48d21f52 --- /dev/null +++ b/src/graph/__tests__/fixtures/flask-app.py @@ -0,0 +1,43 @@ +from flask import Flask, Blueprint + +app = Flask(__name__) +admin = Blueprint("admin", __name__) + + +@app.route("/health") +def health(): + return {"status": "ok"} + + +@app.route("/users/", methods=["POST", "PUT"]) +async def replace_user(user_id): + return {"user_id": user_id} + + +@app.get("/ready") +def ready(): + return None + + +@admin.route("/settings", methods=["DELETE"]) +def delete_settings(): + return None + + +@admin.post("/settings") +def create_settings(): + return None + + +# A blank line and a comment are legal between decorator and handler. +@admin.route("/cache") + +# expired entries +def clear_cache(): + return None + + +class Custom: + @app.route("/probe") + def probe(self): + return None diff --git a/src/graph/__tests__/resolver-flask-integration.test.ts b/src/graph/__tests__/resolver-flask-integration.test.ts new file mode 100644 index 00000000..02c3506b --- /dev/null +++ b/src/graph/__tests__/resolver-flask-integration.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { rebuildGraph } from "../maintenance.js"; +import { openSqlite } from "../db/sqlite.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Flask resolver integration", () => { + it("persists route nodes and resolved function_ref edges through a real build", async () => { + const root = mkdtempSync(join(tmpdir(), "mex-flask-integration-")); + roots.push(root); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, ".mex"), { recursive: true }); + writeFileSync(join(root, ".mex", "ROUTER.md"), "# Router\n"); + writeFileSync(join(root, "requirements.txt"), "flask>=3.0\n"); + writeFileSync( + join(root, "src", "app.py"), + [ + "from flask import Flask", + "app = Flask(__name__)", + "", + "@app.route('/health', methods=['GET', 'POST'])", + "async def health():", + " return {'ok': True}", + "", + ].join("\n"), + ); + + const result = await rebuildGraph(root); + expect(result.status.status).toBe("fresh"); + + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + const routes = db.prepare( + "SELECT id, name, signature FROM nodes WHERE kind = 'route' ORDER BY name", + ).all() as Array<{ id: string; name: string; signature: string }>; + expect(routes.map((route) => route.name)).toEqual(["GET /health", "POST /health"]); + expect(routes[0]!.signature).toBe("GET /health -> health"); + + const resolved = db.prepare( + "SELECT e.target, n.name AS route_name FROM edges e JOIN nodes n ON n.id = e.source" + + " WHERE e.kind = 'references' AND e.provenance = 'framework' AND e.resolution_method = 'framework'", + ).all() as Array<{ target: string; route_name: string }>; + expect(resolved).toHaveLength(2); + for (const edge of resolved) { + expect(edge.route_name).toMatch(/ \/health$/); + const target = db.prepare("SELECT name FROM nodes WHERE id = ?").get(edge.target) as { name: string }; + expect(target.name).toBe("health"); + } + } finally { + db.close(); + } + }); +}); diff --git a/src/graph/__tests__/resolver-flask.test.ts b/src/graph/__tests__/resolver-flask.test.ts new file mode 100644 index 00000000..9f98777d --- /dev/null +++ b/src/graph/__tests__/resolver-flask.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { extractFile, loadGrammars } from "../extraction/index.js"; +import { generateNodeId } from "../extraction/node-id.js"; +import { flaskResolver } from "../resolution/frameworks/flask.js"; +import { FRAMEWORK_RESOLVERS } from "../resolution/frameworks/index.js"; +import type { GraphNode } from "../types.js"; +import type { ResolutionContext } from "../resolution/types.js"; + +const FILE_PATH = "src/flask-app.py"; +const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "flask-app.py"); +const source = readFileSync(fixturePath, "utf-8"); + +describe("Flask framework resolver", () => { + let pythonNodes: GraphNode[]; + + beforeAll(async () => { + await loadGrammars(["python"]); + pythonNodes = extractFile(FILE_PATH, source, "python")!.nodes.map((node) => ({ + ...node, + updatedAt: 0, + })); + }); + + it.each([ + ["a from-import of the app class", { "src/app.py": "from flask import Flask\napp = Flask(__name__)\n" }], + ["a plain module import", { "src/app.py": "import flask\n\napp = flask.Flask(__name__)\n" }], + ["a submodule import", { "src/views.py": "from flask.views import View\n" }], + ])("detects Flask from %s", (_name, files) => { + expect(flaskResolver.detect(fakeContext([], files))).toBe(true); + }); + + it("does not detect similarly named packages or unrelated Python", () => { + const context = fakeContext([], { + "src/app.py": "import flask_restful\nfrom flask_restful import Api\n", + "src/other.py": "from fastapi import FastAPI\n", + }); + expect(flaskResolver.detect(context)).toBe(false); + }); + + it("extracts stable route nodes with converters preserved and methods fanned out", () => { + const result = flaskResolver.extract!(FILE_PATH, source); + const expectedRoutes = [ + "GET /health", + "POST /users/", + "PUT /users/", + "GET /ready", + "DELETE /settings", + "POST /settings", + "GET /cache", + "GET /probe", + ]; + + expect(result.nodes.map((node) => node.name)).toEqual(expectedRoutes); + for (const node of result.nodes) { + expect(node).toMatchObject({ kind: "route", language: "python", filePath: FILE_PATH }); + expect(node.id).toBe(generateNodeId(FILE_PATH, "route", node.name, node.name, "flask-route", node.signature)); + } + expect(result.references.map((ref) => [ref.referenceName, ref.referenceKind])).toEqual([ + ["health", "function_ref"], + ["replace_user", "function_ref"], + ["replace_user", "function_ref"], + ["ready", "function_ref"], + ["delete_settings", "function_ref"], + ["create_settings", "function_ref"], + ["clear_cache", "function_ref"], + ["probe", "function_ref"], + ]); + }); + + it("recognizes custom instance names and skips dynamic paths and foreign receivers", () => { + const customSource = [ + "api = Flask(__name__)", + "client = HttpClient()", + "route_path = '/dynamic'", + "@api.get('/ready')", + "def ready(): pass", + "@client.get('/external')", + "def external(): pass", + "@api.route(route_path)", + "def dynamic(): pass", + "", + ].join("\n"); + + const result = flaskResolver.extract!("src/custom.py", customSource); + expect(result.nodes).toMatchObject([{ kind: "route", name: "GET /ready" }]); + expect(result.references).toMatchObject([{ referenceName: "ready" }]); + }); + + it("resolves unambiguous same-file functions and methods", () => { + const result = flaskResolver.extract!(FILE_PATH, source); + const context = fakeContext(pythonNodes); + + for (const handler of ["health", "replace_user", "clear_cache", "probe"]) { + const ref = result.references.find((entry) => entry.referenceName === handler)!; + const target = pythonNodes.find((node) => node.name === handler)!; + expect(flaskResolver.resolve(ref, context)).toMatchObject({ + targetNodeId: target.id, + confidence: 1, + resolvedBy: "framework", + }); + } + }); + + it("leaves missing, cross-file-only, and ambiguous handlers unresolved", () => { + const ref = flaskResolver.extract!(FILE_PATH, source).references[0]!; + const crossFile = node("function:cross-file", "health", "src/other.py"); + expect(flaskResolver.resolve(ref, fakeContext([crossFile]))).toBeNull(); + expect(flaskResolver.resolve(ref, fakeContext([]))).toBeNull(); + + const sameFile = node("function:same-file", "health", FILE_PATH); + const duplicate = node("method:duplicate", "health", FILE_PATH, "method"); + expect(flaskResolver.resolve(ref, fakeContext([sameFile, duplicate]))).toBeNull(); + }); + + it("ignores non-Python files and is registered", () => { + expect(flaskResolver.extract!("src/app.ts", "@app.get('/health')\ndef health(): pass")) + .toEqual({ nodes: [], references: [] }); + expect(FRAMEWORK_RESOLVERS).toContain(flaskResolver); + }); +}); + +function node( + id: string, + name: string, + filePath: string, + kind: "function" | "method" = "function", +): GraphNode { + return { + id, + kind, + name, + qualifiedName: name, + filePath, + language: "python", + startLine: 1, + endLine: 2, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; +} + +function fakeContext(nodes: GraphNode[], files: Record = {}): ResolutionContext { + return { + getNodesInFile: (path) => nodes.filter((entry) => entry.filePath === path), + getNodesByName: (name) => nodes.filter((entry) => entry.name === name), + getNodesByQualifiedName: (name) => nodes.filter((entry) => entry.qualifiedName === name), + getNodesByKind: (kind) => nodes.filter((entry) => entry.kind === kind), + getNodeById: (id) => nodes.find((entry) => entry.id === id) ?? null, + fileExists: (path) => path in files, + readFile: (path) => files[path] ?? null, + getProjectRoot: () => "/repo", + getAllFiles: () => Object.keys(files), + }; +} diff --git a/src/graph/resolution/frameworks/flask.ts b/src/graph/resolution/frameworks/flask.ts new file mode 100644 index 00000000..7d1791f9 --- /dev/null +++ b/src/graph/resolution/frameworks/flask.ts @@ -0,0 +1,183 @@ +import { canonicalNodeIdentity, generateNodeId } from "../../extraction/node-id.js"; +import type { GraphNode } from "../../types.js"; +import type { + FrameworkExtractionResult, + FrameworkResolver, + ResolvedRef, + UnresolvedRef, +} from "../types.js"; + +const FRAMEWORK_INSTANCE = /^\s*([A-Za-z_]\w*)\s*=\s*(?:Flask|Blueprint)\s*\(/gm; +const ROUTE_DECORATOR = /^(\s*)@([A-Za-z_]\w*)\.(route|get|post|put|patch|delete|options|head)\s*\((.*)\)\s*(?:#.*)?$/; +const METHODS_LIST = /(?:^|[,{\s])methods\s*=\s*\[([^\]]*)\]/; +const HANDLER = /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/; +const FLASK_IMPORT = /(?:^|\r?\n)\s*(?:from\s+flask(?:\.[\w.]+)?\s+import\s|import\s+flask\b)/; + +/** A decorator line whose route has not yet met its handler. */ +interface PendingRoute { + method: string; + path: string; + line: number; + startColumn: number; + endColumn: number; +} + +export const flaskResolver: FrameworkResolver = { + name: "flask", + languages: ["python"], + detect(context) { + // Detection runs against the staged corpus, and dependency manifests are + // not staged files — only source is. A Flask project always has a Python + // module importing flask, so the import is the reliable observable here; + // `flask_restful` and friends do not match (`import flask` requires the + // word boundary). + return context.getAllFiles().some((filePath) => { + if (!filePath.toLowerCase().endsWith(".py")) return false; + const content = context.readFile(filePath); + return content ? FLASK_IMPORT.test(content) : false; + }); + }, + claimsReference: (name) => /^[A-Za-z_]\w*$/.test(name), + extract(filePath, content): FrameworkExtractionResult { + if (!filePath.toLowerCase().endsWith(".py")) { + return { nodes: [], references: [] }; + } + + const nodes: GraphNode[] = []; + const references: UnresolvedRef[] = []; + const pendingRoutes: PendingRoute[] = []; + const routeReceivers = new Set( + [...content.matchAll(FRAMEWORK_INSTANCE)].map((match) => match[1]!), + ); + const lines = content.split(/\r?\n/); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]!; + const decorator = ROUTE_DECORATOR.exec(line); + if (decorator && routeReceivers.has(decorator[2]!)) { + for (const route of parseRoute(decorator[3]!, decorator[4]!, lineIndex, decorator[1]!.length)) { + pendingRoutes.push(route); + } + continue; + } + + if (pendingRoutes.length === 0) continue; + // Stacked decorators and blank/comment lines are legal between the + // decorator and its def; only a real statement ends the wait. + if (/^\s*@/.test(line)) continue; + if (/^\s*(?:#.*)?$/.test(line)) continue; + + const handler = HANDLER.exec(line); + if (handler) { + emitRoutes(filePath, handler[1]!, pendingRoutes, nodes, references); + } + pendingRoutes.length = 0; + } + + return { nodes, references }; + }, + resolve(ref, context): ResolvedRef | null { + if (ref.referenceKind !== "function_ref") return null; + const candidates = context.getNodesInFile(ref.filePath).filter((node) => ( + (node.kind === "function" || node.kind === "method") + && node.name === ref.referenceName + )); + // The decorator proves the handler name, not a repository-global target; + // same-file is the only context that binds it unambiguously. + if (candidates.length !== 1) return null; + + return { + original: ref, + targetNodeId: candidates[0]!.id, + confidence: 1, + resolvedBy: "framework", + }; + }, +}; + +/** + * Turn one decorator's arguments into 1..n routes. + * + * `@app.route("/x")` means GET by default. `methods=["POST", "PUT"]` fans out + * to one route per explicitly declared method. Shortcut decorators + * (`@app.get("/x")`) carry their method in the name. Path converters such as + * `/users/` are preserved verbatim — they are the route's + * identity, and normalizing them would collide distinct routes. + */ +function parseRoute( + decoratorName: string, + argsText: string, + lineIndex: number, + startColumn: number, +): PendingRoute[] { + const path = firstStringArgument(argsText); + if (path === null) return []; + + const methods = decoratorName === "route" + ? declaredMethods(argsText) ?? ["GET"] + : [decoratorName.toUpperCase()]; + return methods.map((method) => ({ + method, + path, + line: lineIndex, + startColumn, + endColumn: startColumn + argsText.length, + })); +} + +/** The first positional string-literal argument, or null when absent/dynamic. */ +function firstStringArgument(argsText: string): string | null { + const match = /^\s*(["'])([^"'\\]*)\1/.exec(argsText); + return match ? match[2]! : null; +} + +/** Methods from `methods=[...]`, each validated as an uppercase identifier. */ +function declaredMethods(argsText: string): string[] | null { + const match = METHODS_LIST.exec(argsText); + if (!match) return null; + const methods: string[] = []; + for (const literal of match[1]!.matchAll(/(["'])([^"'\\]*)\1/g)) { + const method = literal[2]!.trim().toUpperCase(); + if (/^[A-Z]+$/.test(method)) methods.push(method); + } + return methods.length > 0 ? methods : null; +} + +function emitRoutes( + filePath: string, + handler: string, + routes: PendingRoute[], + nodes: GraphNode[], + references: UnresolvedRef[], +): void { + for (const route of routes) { + const name = `${route.method} ${route.path}`; + const signature = `${name} -> ${handler}`; + const id = generateNodeId(filePath, "route", name, name, "flask-route", signature); + nodes.push({ + id, + identityKey: canonicalNodeIdentity(filePath, "route", name, "flask-route", signature), + kind: "route", + name, + qualifiedName: name, + filePath, + language: "python", + startLine: route.line + 1, + endLine: route.line + 1, + startColumn: route.startColumn, + endColumn: route.endColumn, + signature, + isExported: false, + updatedAt: 0, + }); + references.push({ + fromNodeId: id, + referenceName: handler, + referenceKind: "function_ref", + filePath, + language: "python", + line: route.line, + column: route.startColumn, + }); + } +} diff --git a/src/graph/resolution/frameworks/index.ts b/src/graph/resolution/frameworks/index.ts index 2949603e..da5194c5 100644 --- a/src/graph/resolution/frameworks/index.ts +++ b/src/graph/resolution/frameworks/index.ts @@ -1,6 +1,8 @@ import { expressResolver } from "./express.js"; +import { flaskResolver } from "./flask.js"; import type { FrameworkResolver } from "../types.js"; /** Reference registry. Community resolvers add one entry here. */ -export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver]; +export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver, flaskResolver]; export { expressResolver } from "./express.js"; +export { flaskResolver } from "./flask.js";