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";