Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions src/graph/__tests__/fixtures/flask-app.py
Original file line number Diff line number Diff line change
@@ -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/<int:user_id>", 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
60 changes: 60 additions & 0 deletions src/graph/__tests__/resolver-flask-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
158 changes: 158 additions & 0 deletions src/graph/__tests__/resolver-flask.test.ts
Original file line number Diff line number Diff line change
@@ -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/<int:user_id>",
"PUT /users/<int:user_id>",
"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<string, string> = {}): 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),
};
}
Loading
Loading