diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f74fe2ab..a9a000651 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+- Flask routes registered with `add_url_rule(…)` are now found and linked to their view function or class-based view. So are routes registered through a project's own helper that passes a list of paths and a `view_func=`. flaskbb, which registers every view that way, went from no routes to over 100.
- In Lua and Luau, a call through a standard or host library table (`string`, `table`, `io`, `vim`, `ngx`, busted's `assert`…) or a string method like `line:find(…)` no longer lands on the one project method with the same name. Functions the project itself defines on those tables still link. On kong, the specs' `assert.truthy(…)` and `assert.falsy(…)` used to land on a condition helper over 1,700 times; on telescope, `line:find(…)` on the picker's `find`.
- In PHP, a call through a variable nothing types no longer lands on an unrelated class's method just because it's the only method with that name. The variable now needs to be named after the method's class, or after a class that inherits it (`$page->save()` where `Page` extends `Entity`). On guzzle, the tests' `$response->getHeaderLine()` used to land on a test double; on symfony/console, `$e->getMessage()` on a progress bar.
- In C#, a bare name like `TestContext`, `Easing` or `Helper()` now resolves only to a member of the class it's written in, its outer classes, their base classes, or a type brought in with `using static`. That includes a `global using static` and a project file's ``. On eShop, test classes' `TestContext` used to land on one other test class's property; on Newtonsoft.Json, `TypeNameHandling.All` now reaches the enum rather than a same-named property.
diff --git a/__tests__/flask-url-rules.test.ts b/__tests__/flask-url-rules.test.ts
new file mode 100644
index 000000000..b89b7b238
--- /dev/null
+++ b/__tests__/flask-url-rules.test.ts
@@ -0,0 +1,74 @@
+/**
+ * Flask's imperative registration is a route too: `bp.add_url_rule(…)` with a
+ * `view_func=` (a function, or a class-based view's `X.as_view('name')`), and
+ * a project's own wrapper that hands a list of paths and a `view_func=` over
+ * — flaskbb registers every view through `register_view(bp, routes=[…],
+ * view_func=…)` and had no routes at all.
+ */
+import { describe, it, expect, afterAll, beforeAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+let root = '';
+let cg: CodeGraph;
+
+beforeAll(async () => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flask-rules-'));
+ const files: Record = {
+ 'requirements.txt': 'flask\n',
+ 'app/helpers.py': `def register_view(bp_or_app, routes, view_func, **kwargs):
+ for route in routes:
+ bp_or_app.add_url_rule(route, view_func=view_func, **kwargs)
+`,
+ 'app/auth.py': `from flask import Blueprint
+from flask.views import MethodView
+from app.helpers import register_view
+
+auth = Blueprint("auth", __name__)
+
+
+class Login(MethodView):
+ def get(self):
+ return "form"
+
+ def post(self):
+ return "ok"
+
+
+def logout():
+ return "bye"
+
+
+register_view(auth, routes=["/login", "/signin"], view_func=Login.as_view("login"))
+auth.add_url_rule("/logout", "logout", logout, methods=["POST"])
+auth.add_url_rule("/health", view_func=logout)
+`,
+ };
+ for (const [rel, content] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
+ fs.writeFileSync(path.join(root, rel), content);
+ }
+ cg = await CodeGraph.init(root, { index: true });
+});
+
+afterAll(() => {
+ cg?.close();
+ if (root) fs.rmSync(root, { recursive: true, force: true });
+});
+
+describe('Flask add_url_rule routes', () => {
+ it('names each path and method, and links the view', () => {
+ const routes = cg.getNodesByKind('route').filter((r) => r.filePath === 'app/auth.py');
+ const handler = (name: string) => {
+ const route = routes.find((r) => r.name === name)!;
+ return cg.getOutgoingEdges(route.id).map((e) => cg.getNode(e.target)!.name);
+ };
+ expect(routes.map((r) => r.name).sort()).toEqual(['ANY /login', 'ANY /signin', 'GET /health', 'POST /logout']);
+ expect(handler('ANY /login')).toContain('Login');
+ expect(handler('POST /logout')).toContain('logout');
+ // The wrapper's own `add_url_rule(route, …)` names no path: no route there.
+ expect(cg.getNodesByKind('route').some((r) => r.filePath === 'app/helpers.py')).toBe(false);
+ });
+});
diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts
index 2f1a7c984..7ef69f0df 100644
--- a/src/resolution/frameworks/python.ts
+++ b/src/resolution/frameworks/python.ts
@@ -223,9 +223,10 @@ export const flaskResolver: FrameworkResolver = {
language: 'python',
});
const restful = extractFlaskRestful(filePath, safe);
+ const rules = extractFlaskUrlRules(filePath, safe);
return {
- nodes: [...decorator.nodes, ...restful.nodes],
- references: [...decorator.references, ...restful.references],
+ nodes: [...decorator.nodes, ...restful.nodes, ...rules.nodes],
+ references: [...decorator.references, ...restful.references, ...rules.references],
};
},
};
@@ -537,6 +538,96 @@ function extractFlaskRestful(filePath: string, safe: string): FrameworkExtractio
return { nodes, references };
}
+/**
+ * Flask's imperative registration: `bp.add_url_rule('/path', 'name',
+ * view_func=View.as_view('name'), methods=[…])`, and a project's own wrapper
+ * around it — any call handing a list of path strings and a `view_func=`
+ * over, like flaskbb's `register_view(auth, routes=["/login"],
+ * view_func=Login.as_view("login"))`. A class-based view (`X.as_view(…)`)
+ * serves whatever verbs it defines, so its route is ANY unless `methods=`
+ * says; a plain function defaults to GET, as Flask does.
+ */
+function extractFlaskUrlRules(filePath: string, safe: string): FrameworkExtractionResult {
+ const nodes: Node[] = [];
+ const references: UnresolvedRef[] = [];
+ const now = Date.now();
+ const seen = new Set();
+ const opener = /(\.add_url_rule|\b[A-Za-z_]\w*)\s*\(/g;
+ let m: RegExpExecArray | null;
+ while ((m = opener.exec(safe)) !== null) {
+ const open = m.index + m[0].length;
+ const args = balancedArgs(safe, open);
+ if (args === null || !/\bview_func\s*=|\.add_url_rule/.test(m[1]! + args)) continue;
+ const isRule = m[1] === '.add_url_rule';
+ let paths: string[] = [];
+ if (isRule) {
+ const first = /^\s*(?:rule\s*=\s*)?(['"])([^'"]*)\1/.exec(args);
+ if (first) paths = [first[2]!];
+ } else {
+ const list = /(?:^|,)\s*(?:routes|rules|urls|paths)?\s*=?\s*\[((?:\s*(['"])[^'"]*\2\s*,?)+)\]/.exec(args);
+ if (list) paths = (list[1]!.match(/(['"])([^'"]*)\1/g) ?? []).map((q) => q.slice(1, -1));
+ }
+ paths = paths.filter((p) => p.startsWith('/'));
+ if (paths.length === 0) continue;
+ // `view_func=X` / `view_func=X.as_view(…)`, or add_url_rule's third positional argument.
+ const expr = /\bview_func\s*=\s*([A-Za-z_][\w.]*)/.exec(args)?.[1] ??
+ (isRule ? /^\s*(['"])[^'"]*\1\s*,\s*(?:(['"])[^'"]*\2|None)\s*,\s*([A-Za-z_][\w.]*)/.exec(args)?.[3] : undefined);
+ if (!expr) continue;
+ const classView = expr.endsWith('.as_view');
+ const target = classView ? expr.slice(0, -'.as_view'.length) : expr;
+ const listed = /\bmethods\s*=\s*[[(]([^\])]*)[\])]/.exec(args)?.[1];
+ const methods = listed
+ ? (listed.match(/['"](\w+)['"]/g) ?? []).map((q) => q.slice(1, -1).toUpperCase())
+ : [classView ? 'ANY' : 'GET'];
+ const line = safe.slice(0, m.index).split('\n').length;
+ const name = target.split('.').pop()!;
+ for (const routePath of paths) {
+ for (const method of methods) {
+ const id = `route:${filePath}:${line}:${method}:${routePath}`;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ nodes.push({
+ id,
+ kind: 'route',
+ name: `${method} ${routePath}`,
+ qualifiedName: `${filePath}::${method}:${routePath}`,
+ filePath,
+ startLine: line,
+ endLine: line,
+ startColumn: 0,
+ endColumn: 0,
+ language: 'python',
+ updatedAt: now,
+ });
+ references.push({ fromNodeId: id, referenceName: name, referenceKind: 'references', line, column: 0, filePath, language: 'python' });
+ }
+ }
+ opener.lastIndex = open + args.length;
+ }
+ return { nodes, references };
+}
+
+/** The text between an opening `(` at `open` and its matching `)`, or null. Strings are skipped. */
+function balancedArgs(text: string, open: number): string | null {
+ let depth = 1;
+ let quote = '';
+ for (let i = open; i < text.length && i < open + 4000; i++) {
+ const ch = text[i]!;
+ if (quote) {
+ if (ch === '\\') i++;
+ else if (ch === quote) quote = '';
+ continue;
+ }
+ if (ch === '"' || ch === "'") quote = ch;
+ else if (ch === '(' || ch === '[' || ch === '{') depth++;
+ else if (ch === ')' || ch === ']' || ch === '}') {
+ depth--;
+ if (depth === 0) return text.slice(open, i);
+ }
+ }
+ return null;
+}
+
// Directory patterns
const MODEL_DIRS = ['models', 'app/models', 'src/models'];
const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];