fix(flask): add_url_rule and view_func registrations are routes (#2154)

`bp.add_url_rule('/path', 'name', view_func | positional view, methods=[…])`
and a project's own wrapper that hands a list of paths and a `view_func=`
over (flaskbb's `register_view(auth, routes=[…], view_func=X.as_view(…))`)
now produce route nodes linked to the view. A class-based view is ANY unless
`methods=` says; a function defaults to GET. A call whose path isn't a
string literal (the wrapper's own `add_url_rule(route, …)`) adds nothing.

flaskbb 0 → 103 routes (+104 edges); flask's own tests +27; CTFd, microblog,
bluelog byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 11:03:06 +00:00
committed by GitHub
parent be91a3120b
commit 11cc3d9647
3 changed files with 168 additions and 2 deletions
+93 -2
View File
@@ -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<string>();
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'];