Skip to content
Merged
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 @@ -24,6 +24,7 @@
- Fixed finding signatures so equivalent evidence remains stable across re-reviews, thanks @rohitjavvadi.
- Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi.
- Improved Node route mapping to preserve literal Express and Hono mount prefixes, thanks @rohitjavvadi.
- Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi.
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.
Expand Down
94 changes: 94 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13220,6 +13220,100 @@ add_executable(headerapp include/headers.hpp)
expect(admin?.trustBoundaries).toContain("auth");
});

it("maps static Flask blueprint url prefixes", async () => {
const root = await fixtureRoot("clawpatch-python-flask-blueprint-prefixes-");
await writeFixture(root, "requirements.txt", "Flask\npytest\n");
await writeFixture(
root,
"web/app.py",
[
"from flask import Blueprint, Flask",
"",
"app = Flask(__name__)",
"API_PREFIX = '/dynamic'",
"api_bp = Blueprint('api', __name__, url_prefix='/api')",
"registered_bp = Blueprint('registered', __name__)",
"dynamic_bp = Blueprint('dynamic', __name__, url_prefix=API_PREFIX)",
"runtime_bp = Blueprint('runtime', __name__)",
"overridden_bp = Blueprint('overridden', __name__, url_prefix='/constructor')",
"none_bp = Blueprint('none', __name__, url_prefix='/kept')",
"none_comment_bp = Blueprint('none_comment', __name__, url_prefix='/kept-comment')",
"constructor_comment_bp = Blueprint(",
" 'constructor_comment',",
" __name__,",
" url_prefix='/constructor-comment' # use constructor literal",
")",
"literal_comment_bp = Blueprint('literal_comment', __name__, url_prefix='/constructor')",
"app.register_blueprint(registered_bp, url_prefix='/registered')",
"app.register_blueprint(runtime_bp, url_prefix=API_PREFIX)",
"app.register_blueprint(overridden_bp, url_prefix=API_PREFIX)",
"app.register_blueprint(none_bp, url_prefix=None)",
"app.register_blueprint(",
" none_comment_bp,",
" url_prefix=None # use constructor default",
")",
"app.register_blueprint(",
" literal_comment_bp,",
" url_prefix='/literal' # use literal override",
")",
"",
"@api_bp.route('/users')",
"def users():",
" return 'users'",
"",
"@registered_bp.route('/reports', methods=['POST'])",
"def reports():",
" return 'reports'",
"",
"@dynamic_bp.route('/metrics')",
"def metrics():",
" return 'metrics'",
"",
"@runtime_bp.route('/events')",
"def events():",
" return 'events'",
"",
"@overridden_bp.route('/health')",
"def health():",
" return 'health'",
"",
"@none_bp.route('/ready')",
"def ready():",
" return 'ready'",
"",
"@none_comment_bp.route('/ready')",
"def ready_with_comment():",
" return 'ready'",
"",
"@constructor_comment_bp.route('/ready')",
"def ready_with_constructor_comment():",
" return 'ready'",
"",
"@literal_comment_bp.route('/ready')",
"def ready_with_literal_comment():",
" return 'ready'",
"",
].join("\n"),
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const titles = result.features.map((feature) => feature.title);

expect(titles).toContain("Flask route GET /api/users");
expect(titles).toContain("Flask route POST /registered/reports");
expect(titles).toContain("Flask route GET /metrics");
expect(titles).toContain("Flask route GET /events");
expect(titles).toContain("Flask route GET /health");
expect(titles).toContain("Flask route GET /kept/ready");
expect(titles).toContain("Flask route GET /kept-comment/ready");
expect(titles).toContain("Flask route GET /constructor-comment/ready");
expect(titles).toContain("Flask route GET /literal/ready");
expect(titles).not.toContain("Flask route GET /dynamic/metrics");
expect(titles).not.toContain("Flask route GET /dynamic/events");
expect(titles).not.toContain("Flask route GET /constructor/health");
});

it("maps root-level Flask entry files and non-list methods", async () => {
const root = await fixtureRoot("clawpatch-python-flask-root-routes-");
await writeFixture(root, "requirements.txt", "Flask\npytest\n");
Expand Down
141 changes: 134 additions & 7 deletions src/mappers/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,8 @@ function sourceLooksFlask(source: string): boolean {

function parseFlaskRoutes(filePath: string, source: string): FlaskRoute[] {
const routes: FlaskRoute[] = [];
let pending: Array<{ routePath: string; methods: string[] }> = [];
const prefixes = parseFlaskBlueprintPrefixes(source);
let pending: Array<{ target: string; routePath: string; methods: string[] }> = [];
let decoratorSource: string | null = null;
let decoratorDepth = 0;
for (const line of source.split("\n")) {
Expand Down Expand Up @@ -1257,7 +1258,12 @@ function parseFlaskRoutes(filePath: string, source: string): FlaskRoute[] {
const functionName = /^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/u.exec(line)?.[1];
if (functionName !== undefined && pending.length > 0) {
for (const item of pending) {
routes.push({ filePath, functionName, ...item });
routes.push({
filePath,
functionName,
routePath: combineFastApiPaths(prefixes.get(item.target) ?? "", item.routePath),
methods: item.methods,
});
}
pending = [];
continue;
Expand All @@ -1279,23 +1285,144 @@ function startsFlaskRouteDecorator(line: string): boolean {
return /^@[A-Za-z_][A-Za-z0-9_.]*\.route\(/u.test(line);
}

function parseFlaskRouteDecorator(line: string): { routePath: string; methods: string[] } | null {
const match = /^\s*@[A-Za-z_][A-Za-z0-9_.]*\.route\(\s*(["'])(.*?)\1(.*)\)\s*(?:#.*)?$/u.exec(
function parseFlaskRouteDecorator(
line: string,
): { target: string; routePath: string; methods: string[] } | null {
const match = /^\s*@([A-Za-z_][A-Za-z0-9_.]*)\.route\(\s*(["'])(.*?)\2(.*)\)\s*(?:#.*)?$/u.exec(
line,
);
if (match?.[2] === undefined) {
const target = match?.[1];
const routePath = match?.[3];
if (target === undefined || routePath === undefined) {
return null;
}
const methods = parsePythonRouteMethods(match[3] ?? "");
const methods = parsePythonRouteMethods(match?.[4] ?? "");
if (methods === null) {
return null;
}
return {
routePath: match[2],
target,
routePath,
methods,
};
}

function parseFlaskBlueprintPrefixes(source: string): Map<string, string | null> {
const prefixes = new Map<string, string | null>();
for (const [target, prefix] of parseFlaskBlueprintConstructorPrefixes(source)) {
prefixes.set(target, prefix);
}
for (const [target, prefix] of parseFlaskBlueprintRegistrationPrefixes(source)) {
prefixes.set(target, prefix);
}
return prefixes;
}

function parseFlaskBlueprintConstructorPrefixes(source: string): Map<string, string> {
const prefixes = new Map<string, string>();
const blueprintCallPattern = /\bBlueprint\s*\(/gu;
for (const match of source.matchAll(blueprintCallPattern)) {
const callStart = match.index;
const openParenIndex = source.indexOf("(", callStart);
if (openParenIndex === -1) {
continue;
}
const prefixSegment = source.slice(0, callStart).trimEnd();
const varName =
/(?:^|[\n;])\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n;]+)?=\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)?$/u.exec(
prefixSegment,
)?.[1];
if (varName === undefined) {
continue;
}
const closeParenIndex = findBalancedParenthesis(source, openParenIndex + 1);
if (closeParenIndex === -1) {
continue;
}
const args = splitTopLevelPythonArgs(source.slice(openParenIndex + 1, closeParenIndex));
const prefix = parsePythonKeywordStringArg(args, "url_prefix");
if (prefix !== null) {
prefixes.set(varName, prefix);
}
}
return prefixes;
}

function parseFlaskBlueprintRegistrationPrefixes(source: string): Map<string, string | null> {
const prefixes = new Map<string, string | null>();
const registerCallPattern = /\.register_blueprint\s*\(/gu;
for (const match of source.matchAll(registerCallPattern)) {
Comment on lines +1353 to +1354
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore commented register_blueprint calls in prefix parsing

The new registration-prefix parser scans raw source with source.matchAll(/\.register_blueprint\s*\(/) and does not exclude comments or string literals, so a commented line like # app.register_blueprint(api_bp, url_prefix='/api') is treated as a real registration. In files with blueprint decorators, this can incorrectly override/clear the blueprint prefix and emit wrong route paths in the feature map even though runtime behavior is unchanged.

Useful? React with 👍 / 👎.

const callStart = match.index;
const openParenIndex = source.indexOf("(", callStart);
if (openParenIndex === -1) {
continue;
}
const closeParenIndex = findBalancedParenthesis(source, openParenIndex + 1);
if (closeParenIndex === -1) {
continue;
}
const args = splitTopLevelPythonArgs(source.slice(openParenIndex + 1, closeParenIndex));
const target = args[0]?.trim();
if (target === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target)) {
continue;
}
const prefixValue = parsePythonKeywordArgValue(args, "url_prefix");
if (prefixValue !== undefined) {
const normalizedPrefixValue = stripPythonInlineComment(prefixValue).trim();
if (normalizedPrefixValue === "None") {
continue;
}
prefixes.set(target, pythonStringLiteralValue(normalizedPrefixValue));
}
}
return prefixes;
}

function parsePythonKeywordStringArg(args: string[], name: string): string | null {
const value = parsePythonKeywordArgValue(args, name);
return value === undefined
? null
: pythonStringLiteralValue(stripPythonInlineComment(value).trim());
}

function parsePythonKeywordArgValue(args: string[], name: string): string | undefined {
const pattern = new RegExp(`^\\s*${name}\\s*=\\s*([\\s\\S]*)$`, "u");
return pattern.exec(args.find((arg) => pattern.test(arg)) ?? "")?.[1];
}

function stripPythonInlineComment(source: string): string {
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (char === undefined) {
break;
}
if (quote !== null) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "#") {
return source.slice(0, index);
}
}
return source;
}

function parsePythonRouteMethods(args: string): string[] | null {
const methodsIndex = args.search(/\bmethods\s*=/u);
if (methodsIndex === -1) {
Expand Down