diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1fda31..35565f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added - A bounded Next.js App Router resolver turning `app/**/route.ts|js` modules (including `src/app` roots) into route nodes: one per exported HTTP handler (`GET` through `HEAD`), with the URL path derived from the route file's directory, dynamic segments such as `[id]` and catch-alls preserved verbatim, and route groups `(marketing)` excluded the way Next resolves them. Same-file handlers resolve only when unambiguous; Pages Router, layouts, and pages stay out of scope (#95). +- 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 an ordinal in the role so a route declared twice in one file cannot collide ids and fail the build — Flask path converters such as `` are preserved verbatim, `methods=` is read from list or tuple literals (unreadable values skip the route rather than guessing `GET`), receiver names include Blueprint instances carrying a static `url_prefix` and Flask/Blueprint objects imported from other modules, docstrings and comments are blanked before scanning, 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). ### Fixed diff --git a/src/graph/__tests__/fixtures/flask-app.py b/src/graph/__tests__/fixtures/flask-app.py new file mode 100644 index 00000000..a83fc2a6 --- /dev/null +++ b/src/graph/__tests__/fixtures/flask-app.py @@ -0,0 +1,50 @@ +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 + +ops = Blueprint("ops", __name__, url_prefix="/admin") + + +@ops.get("/settings") +def admin_settings(): + 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..0f1e256c --- /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 = 'flask-route-handler'", + ).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..2c321e77 --- /dev/null +++ b/src/graph/__tests__/resolver-flask.test.ts @@ -0,0 +1,269 @@ +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 { 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); + + expect(result.nodes.map((node) => node.name)).toEqual([ + "GET /health", + "POST /users/", + "PUT /users/", + "GET /ready", + "DELETE /settings", + "POST /settings", + "GET /cache", + "GET /probe", + "GET /admin/settings", + ]); + for (const node of result.nodes) { + expect(node).toMatchObject({ kind: "route", language: "python", filePath: FILE_PATH }); + expect(node.id.startsWith("route:")).toBe(true); + } + 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"], + ["admin_settings", "function_ref"], + ]); + }); + + it("gives a route declared twice in one file distinct ids (#177 review)", () => { + const custom = [ + "import os", + "from flask import Flask", + "app = Flask(__name__)", + "if os.environ.get('DEBUG'):", + " @app.route('/debug')", + " def debug_on():", + " return 'on'", + "else:", + " @app.route('/debug')", + " def debug_off():", + " return 'off'", + "", + ].join("\n"); + const result = flaskResolver.extract!("src/conditional.py", custom); + expect(result.nodes.map((node) => node.name)).toEqual(["GET /debug", "GET /debug"]); + expect(new Set(result.nodes.map((node) => node.id)).size).toBe(2); + }); + + it("ignores a route decorator shown inside a docstring example (#177 review)", () => { + const custom = [ + "from flask import Flask", + "app = Flask(__name__)", + "", + "def documented():", + ' """Example usage:', + "", + " @app.route('/example')", + " def example():", + " ...", + ' """', + " return 1", + "", + "@app.route('/real')", + "def real():", + " return 2", + "", + ].join("\n"); + const result = flaskResolver.extract!("src/docstring.py", custom); + expect(result.nodes.map((node) => node.name)).toEqual(["GET /real"]); + }); + + it("accepts tuple methods, skips unreadable ones, and refuses interpolated paths (#177 review)", () => { + const custom = [ + "from flask import Flask", + "app = Flask(__name__)", + "ALLOWED = ['GET', 'POST']", + "", + '@app.route("/login", methods=("GET", "POST"))', + "def login():", + " return 1", + "", + '@app.route("/items", methods=ALLOWED)', + "def items():", + " return 2", + "", + '@app.route(f"/users/{1}")', + "def dynamic():", + " return 3", + "", + ].join("\n"); + const result = flaskResolver.extract!("src/methods.py", custom); + // Tuple fan-out emits both methods; the variable methods= and the + // f-string path skip rather than guess. + expect(result.nodes.map((node) => node.name)).toEqual(["GET /login", "POST /login"]); + }); + + it("treats imported app/blueprint names as receivers (#177 review)", () => { + const views = [ + "from myapp.auth import bp", + "from myapp import app", + "", + '@bp.route("/login", methods=["GET", "POST"])', + "def login():", + " return 1", + "", + "@app.get('/status')", + "def status():", + " return 2", + "", + ].join("\n"); + const result = flaskResolver.extract!("src/views.py", views); + expect(result.nodes.map((node) => node.name)).toEqual([ + "GET /login", + "POST /login", + "GET /status", + ]); + }); + + it("recognizes flask.Flask assignment, type annotations, and multi-line decorators (#177 review)", () => { + const custom = [ + "import flask", + "app = flask.Flask(__name__)", + "api: flask.Blueprint = flask.Blueprint('api', __name__, url_prefix='/api')", + "", + "@api.route(", + ' "/users/",', + ' methods=["GET", "POST"],', + ")", + "def users(user_id):", + " return 1", + "", + ].join("\n"); + const result = flaskResolver.extract!("src/blueprint.py", custom); + expect(result.nodes.map((node) => node.name)).toEqual([ + "GET /api/users/", + "POST /api/users/", + ]); + }); + + it("recognizes custom instance names and skips foreign receivers and dynamic paths", () => { + 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: 0.8, + resolvedBy: "flask-route-handler", + }); + } + }); + + it("leaves missing, cross-file-only, and ambiguous handlers unresolved", () => { + const result = flaskResolver.extract!(FILE_PATH, source).references[0]!; + const crossFile = node("function:cross-file", "health", "src/other.py"); + expect(flaskResolver.resolve(result, fakeContext([crossFile]))).toBeNull(); + expect(flaskResolver.resolve(result, fakeContext([]))).toBeNull(); + + const sameFile = node("function:same-file", "health", FILE_PATH); + const duplicate = node("method:duplicate", "health", FILE_PATH, "method"); + expect(flaskResolver.resolve(result, 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..83ba9adf --- /dev/null +++ b/src/graph/resolution/frameworks/flask.ts @@ -0,0 +1,401 @@ +import { canonicalNodeIdentity, generateNodeId } from "../../extraction/node-id.js"; +import type { GraphNode } from "../../types.js"; +import type { + FrameworkExtractionResult, + FrameworkResolver, + ResolvedRef, + UnresolvedRef, +} from "../types.js"; + +// Receiver creation: `app = Flask(__name__)`, `bp: Blueprint = Blueprint(...)`, +// `app = flask.Flask(__name__)`. Group 2 captures the constructor call's +// argument text start for a static url_prefix read. +const FRAMEWORK_INSTANCE = /^\s*([A-Za-z_]\w*)\s*(?::\s*[\w.\[\]"]+)?\s*=\s*(?:flask\.)?(?:Flask|Blueprint)\s*\(/; +const FROM_IMPORT = /^\s*from\s+([\w.]+)\s+import\s+(.+)$/; +const IMPORTED_NAME = /^([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?/; +const ROUTE_DECORATOR = /^(\s*)@([A-Za-z_]\w*)\.(route|get|post|put|patch|delete|options|head)\s*\(/; +// `methods=` written as a list or a tuple; the value must close on the same +// logical line and hold only quoted names (or it is unreadable). +const METHODS_VALUE = /(?:^|[,({\s])methods\s*=\s*([[(])([^)\]]*)[\])]/; +const PATH_ARG = /^\s*([fFbBuU]{0,2})?(["'])((?:[^"'\\]|\\.)*)\2/; +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)/; +const URL_PREFIX_ARG = /(?:^|[,({\s])url_prefix\s*=\s*(["'])((?:[^"'\\]|\\.)*)\1/; + +/** A route decorator parsed before its handler was seen. */ +interface PendingRoute { + method: string; + path: string; + line: 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[] = []; + + // Blank `#` comments and triple-quoted strings (docstrings) with spaces + // before scanning: a decorator shown inside a docstring example is + // documentation, not a route (#177 review). Offsets and line numbers stay + // valid because only comment content is replaced; single-quoted strings + // survive intact because decorator arguments live in them. + const scannable = blankCommentsAndDocstrings(content); + const logical = mergeLogicalLines(scannable); + + // Route receivers: names assigned Flask/Blueprint in THIS file, plus + // names imported with `from import name` — the usual package + // layout creates the app or Blueprint in `__init__.py` and declares + // routes elsewhere, and detection already proved this is a Flask project + // (#177 review). Names imported FROM flask are framework classes, not + // instances. A Blueprint keeps its static constructor `url_prefix`. + const receivers = new Map(); + const importedNames: Array<{ name: string }> = []; + for (const entry of logical) { + const instance = FRAMEWORK_INSTANCE.exec(entry.text); + if (instance) { + receivers.set(instance[1]!, parseUrlPrefix(entry.text, instance[0].length)); + continue; + } + const imported = FROM_IMPORT.exec(entry.text); + if (imported && !imported[1]!.split(".")[0]!.startsWith("flask")) { + for (const raw of imported[2]!.split(",")) { + const nameMatch = IMPORTED_NAME.exec(raw.trim().replace(/[()]/g, "")); + if (nameMatch) importedNames.push({ name: nameMatch[2] ?? nameMatch[1]! }); + } + } + } + for (const { name } of importedNames) { + if (!receivers.has(name)) receivers.set(name, ""); + } + + for (const entry of logical) { + const line = entry.text; + const decorator = ROUTE_DECORATOR.exec(line); + if (decorator && receivers.has(decorator[2]!)) { + const receiverPrefix = receivers.get(decorator[2]!)!; + const open = line.indexOf("(", decorator[1]!.length + decorator[2]!.length + 1); + const args = readBalanced(line, open); + const route = args === null ? null : parseRoute(decorator[3]!, args, entry.line, receiverPrefix); + if (route) pendingRoutes.push(...route); + continue; + } + + if (pendingRoutes.length === 0) continue; + // Stacked decorators and blank/comment lines are legal between a route + // 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: 0.8, + resolvedBy: "flask-route-handler", + }; + }, +}; + +/** + * Turn one decorator's arguments into 1..n routes. + * + * `@app.route("/x")` means GET by default. `methods=["POST", "PUT"]` — or the + * tuple spelling — fans out to one route per declared method; a `methods=` + * that is present but not a literal list/tuple of strings skips the route + * rather than guessing `GET` (#177 review). Shortcut decorators carry their + * method in the name. Paths that are not fully static — f-strings, + * `%`-format, `{}` placeholders — are skipped, not emitted verbatim. Flask + * path converters such as `/users/` are preserved as written. + * A Blueprint receiver's static `url_prefix` composes in front of the + * decorated path; composing prefixes across `register_blueprint()` calls + * stays out of scope. + */ +function parseRoute( + decoratorName: string, + argsText: string, + lineIndex: number, + receiverPrefix: string, +): PendingRoute[] | null { + const pathMatch = PATH_ARG.exec(argsText); + if (!pathMatch) return null; + const prefix = pathMatch[1] ?? ""; + const rawPath = pathMatch[3]!; + if (/[fF]/.test(prefix)) return null; + if (rawPath.includes("{") || rawPath.includes("}") || rawPath.includes("%")) return null; + const path = composePath(receiverPrefix, rawPath); + + if (decoratorName === "route") { + const methods = declaredMethods(argsText); + if (methods === "unreadable") return null; + return (methods ?? ["GET"]).map((method) => ({ + method: method.toUpperCase(), + path, + line: lineIndex, + endColumn: argsText.length, + })); + } + return [{ + method: decoratorName.toUpperCase(), + path, + line: lineIndex, + endColumn: argsText.length, + }]; +} + +/** `/admin` + `/settings` → `/admin/settings`; "" and `/` fold correctly. */ +function composePath(urlPrefix: string, decoratedPath: string): string { + let prefix = urlPrefix.replace(/\/+$/, ""); + if (prefix && !prefix.startsWith("/")) prefix = "/" + prefix; + let path = decoratedPath; + if (path && !path.startsWith("/")) path = "/" + path; + const full = prefix + path; + return full === "" ? "/" : full; +} + +/** + * Methods from `methods=[...]` or `methods=(...)`. Null when absent (→ GET + * default); the literal string set when readable; "unreadable" when the key + * exists but the value is not a literal list/tuple of plain strings — the + * route is then skipped rather than assigned a guessed GET (#177 review). + */ +function declaredMethods(argsText: string): string[] | "unreadable" | null { + const match = METHODS_VALUE.exec(argsText); + if (!match) { + // The key exists but its value is not a list/tuple literal at all (a + // variable name, an expression) — skip the route rather than guess GET. + return /(?:^|[,({\s])methods\s*=/.test(argsText) ? "unreadable" : null; + } + const inner = match[2]!; + const methods: string[] = []; + for (const part of inner.split(",")) { + const trimmed = part.trim(); + if (trimmed === "") continue; + const literal = /^(["'])([A-Za-z]+)\1$/.exec(trimmed); + if (!literal) return "unreadable"; + methods.push(literal[2]!.toUpperCase()); + } + return methods.length > 0 ? methods : "unreadable"; +} + +/** The static `url_prefix="…"` of a Flask/Blueprint constructor, if any. */ +function parseUrlPrefix(line: string, callStart: number): string { + const open = line.indexOf("(", callStart - 1); + const args = open < 0 ? null : readBalanced(line, open); + if (args === null) return ""; + const match = URL_PREFIX_ARG.exec(args); + return match ? match[2]! : ""; +} + +/** + * Extract balanced argument text starting at the `(` at `open`, string-aware + * so quotes never skew depth. Returns null when the call does not close. + */ +function readBalanced(line: string, open: number): string | null { + if (open < 0) return null; + let depth = 0; + let quote: string | null = null; + for (let i = open; i < line.length; i++) { + const ch = line[i]!; + if (quote) { + if (ch === quote && line[i - 1] !== "\\") quote = null; + continue; + } + if (ch === "\"" || ch === "'") { quote = ch; continue; } + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) return line.slice(open + 1, i); + } + } + return null; +} + +/** + * Join physical lines whose parens are still open into one logical line + * (the text, plus the 0-based index of its first physical line). Black + * routinely splits long decorators over several lines; without this those + * routes produce nothing (#177 review). Paren depth is tracked string-aware + * per physical line; a line that opens more than it closes continues. + */ +function mergeLogicalLines(content: string): Array<{ text: string; line: number }> { + const physical = content.split(/\r?\n/); + const out: Array<{ text: string; line: number }> = []; + let buffer: string | null = null; + let bufferLine = 0; + let depth = 0; + for (let i = 0; i < physical.length; i++) { + const line = physical[i]!; + let openCount = 0; + let closeCount = 0; + let quote: string | null = null; + for (let j = 0; j < line.length; j++) { + const ch = line[j]!; + if (quote) { + if (ch === quote && line[j - 1] !== "\\") quote = null; + continue; + } + if (ch === "\"" || ch === "'") { quote = ch; continue; } + if (ch === "(") openCount++; + else if (ch === ")") closeCount++; + } + const delta = openCount - closeCount; + if (buffer === null) { + if (delta > 0) { + buffer = line; + bufferLine = i; + depth = delta; + } else { + out.push({ text: line, line: i }); + } + continue; + } + buffer += " " + line.trim(); + depth += delta; + if (depth <= 0) { + out.push({ text: buffer, line: bufferLine }); + buffer = null; + depth = 0; + } + } + if (buffer !== null) out.push({ text: buffer, line: bufferLine }); + return out; +} + +function emitRoutes( + filePath: string, + handler: string, + routes: PendingRoute[], + nodes: GraphNode[], + references: UnresolvedRef[], +): void { + const occurrences = new Map(); + for (const route of routes) { + const name = `${route.method} ${route.path}`; + const signature = `${name} -> ${handler}`; + // The same route can legitimately appear twice in one module — a + // conditional `@app.route("/debug")` in both branches, or a redundant + // stacked `@app.route("/x")` + `@app.route("/x", methods=["GET"])`. The + // ordinal in the role keeps ids distinct so a duplicate cannot fail the + // whole build (#177 review). + const ordinal = occurrences.get(name) ?? 0; + occurrences.set(name, ordinal + 1); + const role = `flask-route:${ordinal}`; + const id = generateNodeId(filePath, "route", name, name, role, signature); + nodes.push({ + id, + identityKey: canonicalNodeIdentity(filePath, "route", name, role, signature), + kind: "route", + name, + qualifiedName: name, + filePath, + language: "python", + startLine: route.line + 1, + endLine: route.line + 1, + startColumn: 0, + endColumn: route.endColumn, + signature, + isExported: false, + updatedAt: 0, + }); + references.push({ + fromNodeId: id, + referenceName: handler, + referenceKind: "function_ref", + filePath, + language: "python", + line: route.line, + column: 0, + }); + } +} + +/** + * Blank `#` comments and triple-quoted strings with spaces (newlines kept), + * so every offset and line number stays valid against the original. Single + * and double quoted strings are preserved — decorator arguments live in + * them, and they cannot span lines. + */ +function blankCommentsAndDocstrings(content: string): string { + const out: string[] = []; + type State = "code" | "comment" | "string" | "docstring"; + let state: State = "code"; + let quote = ""; + let docQuote = ""; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + const next = content[i + 1]; + const after = content[i + 2]; + if (state === "code") { + if (ch === "#") { state = "comment"; out.push(" "); continue; } + if ((ch === "\"" || ch === "'") && ch === next && ch === after) { + state = "docstring"; + docQuote = ch; + out.push(" "); + i += 2; + continue; + } + if (ch === "\"" || ch === "'") { state = "string"; quote = ch; out.push(ch); continue; } + out.push(ch); + continue; + } + if (state === "comment") { + if (ch === "\n") { state = "code"; out.push("\n"); } else out.push(" "); + continue; + } + if (state === "string") { + const escaped = content[i - 1] === "\\"; + if (ch === quote && !escaped) { state = "code"; out.push(ch); continue; } + if (ch === "\n") { state = "code"; out.push("\n"); continue; } + out.push(ch); + continue; + } + // docstring: blank everything until the closing triple quote. + if (content.startsWith(docQuote.repeat(3), i) && content[i - 1] !== "\\") { + state = "code"; + out.push(" "); + i += 2; + continue; + } + out.push(ch === "\n" ? "\n" : " "); + } + return out.join(""); +} diff --git a/src/graph/resolution/frameworks/index.ts b/src/graph/resolution/frameworks/index.ts index 3df38a74..c4ba52a2 100644 --- a/src/graph/resolution/frameworks/index.ts +++ b/src/graph/resolution/frameworks/index.ts @@ -1,8 +1,14 @@ import { expressResolver } from "./express.js"; +import { flaskResolver } from "./flask.js"; import { nextjsResolver } from "./nextjs.js"; import type { FrameworkResolver } from "../types.js"; /** Reference registry. Community resolvers add one entry here. */ -export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver, nextjsResolver]; +export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [ + expressResolver, + flaskResolver, + nextjsResolver, +]; export { expressResolver } from "./express.js"; +export { flaskResolver } from "./flask.js"; export { nextjsResolver } from "./nextjs.js";