feat: wire up framework route extraction (#89)

* docs: add framework extract wiring plan

* feat(resolution): replace extractNodes with extract() returning nodes and references

* feat(resolution): add getApplicableFrameworks helper for per-language dispatch

* feat(django): emit route nodes and route->view references in extract()

* feat(flask,fastapi): emit route nodes and route->handler references

* feat(express): emit route nodes and route->handler references

* feat(laravel): emit route nodes and route->handler references

* feat(rails): emit route nodes and route->handler references

* feat(spring): emit route nodes and route->handler references

* feat(go): emit route nodes and route->handler references

* feat(rust): emit route nodes and route->handler references

* feat(aspnet): emit route nodes and route->handler references

* feat(swift,vapor): emit route nodes and route->handler references

* chore(react,svelte): migrate resolvers to extract() interface

* feat(extraction): run framework extractors after tree-sitter parse

* docs: document framework route extraction

* feat(strip-comments): add per-language comment stripper for framework extractors

Replaces comment characters and string-literal contents with spaces (not
removal) so source offsets stay valid for downstream regex match index ->
line number conversion. Handles Python triple-quoted docstrings, Ruby
=begin/=end, Rust nested block comments, and the standard //, #, /* */
forms across the supported languages.

This is consumed by framework extract() methods in a follow-up commit so
that commented-out / docstring routing examples don't surface as phantom
route nodes in the graph.

* feat(frameworks): strip comments before regex extraction (prevents phantom routes)

Pipes the per-language stripCommentsForRegex helper into every framework
extract() that scans raw source: django/flask/fastapi (python.ts),
express, laravel, rails, spring, go, rust, aspnet, vapor, plus
swiftui/uikit struct extraction in swift.ts.

Without this, examples like:

    # path('/admin/', AdminPanel.as_view())
    """ path('/users/', UserListView.as_view()) """
    urlpatterns = [path('/real/', RealView.as_view())]

produced 3 phantom route nodes. Now only the real one is extracted.

Each framework gets a regression test in __tests__/frameworks.test.ts
asserting that line-, block-, docstring- and (where relevant)
heredoc-style commented-out routes do not surface as nodes.

---------

Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
timomeara
2026-05-07 22:03:33 -05:00
committed by GitHub
co-authored by Colby McHenry Claude Opus 4.7
parent 5ab81746e8
commit 74327814ee
23 changed files with 3080 additions and 719 deletions
+190 -207
View File
@@ -5,173 +5,62 @@
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
export const djangoResolver: FrameworkResolver = {
name: 'django',
languages: ['python'],
detect(context: ResolutionContext): boolean {
// Check for Django in requirements.txt or setup.py
detect(context) {
const requirements = context.readFile('requirements.txt');
if (requirements && requirements.includes('django')) {
return true;
}
if (requirements && requirements.toLowerCase().includes('django')) return true;
const setup = context.readFile('setup.py');
if (setup && setup.includes('django')) {
return true;
}
if (setup && setup.toLowerCase().includes('django')) return true;
const pyproject = context.readFile('pyproject.toml');
if (pyproject && pyproject.includes('django')) {
return true;
}
// Check for manage.py (Django signature)
if (pyproject && pyproject.toLowerCase().includes('django')) return true;
return context.fileExists('manage.py');
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Model references
resolve(ref, context) {
if (ref.referenceName.endsWith('Model') || /^[A-Z][a-z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
// Pattern 2: View references
if (ref.referenceName.endsWith('View') || ref.referenceName.endsWith('ViewSet')) {
const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
// Pattern 3: Form references
if (ref.referenceName.endsWith('Form')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
return null;
},
extractNodes(filePath: string, content: string): Node[] {
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
const safe = stripCommentsForRegex(content, 'python');
// Extract URL patterns
// path('route/', view, name='name')
const urlPatterns = [
/path\s*\(\s*['"]([^'"]+)['"],\s*(\w+)/g,
/url\s*\(\s*r?['"]([^'"]+)['"],\s*(\w+)/g,
];
// path('url', handler, name=...) / re_path(r'...', handler) / url(r'...', handler)
// Capture groups: 1=function name, 2=url string, 3=handler expr
// Handler expr may contain one balanced () pair (e.g. View.as_view(), include('x.y'))
const routeRegex = /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([\w.]+(?:\s*\([^)]*\))?)/g;
for (const pattern of urlPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const [, urlPath] = match;
const line = content.slice(0, match.index).split('\n').length;
let match: RegExpExecArray | null;
while ((match = routeRegex.exec(safe)) !== null) {
const [, _fn, urlPath, handlerExpr] = match;
const line = safe.slice(0, match.index).split('\n').length;
nodes.push({
id: `route:${filePath}:${urlPath}:${line}`,
kind: 'route',
name: urlPath!,
qualifiedName: `${filePath}::route:${urlPath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: 'python',
updatedAt: now,
});
}
}
return nodes;
},
};
export const flaskResolver: FrameworkResolver = {
name: 'flask',
detect(context: ResolutionContext): boolean {
const requirements = context.readFile('requirements.txt');
if (requirements && (requirements.includes('flask') || requirements.includes('Flask'))) {
return true;
}
const pyproject = context.readFile('pyproject.toml');
if (pyproject && pyproject.includes('flask')) {
return true;
}
// Check for Flask app pattern in common files
const appFiles = ['app.py', 'application.py', 'main.py', '__init__.py'];
for (const file of appFiles) {
const content = context.readFile(file);
if (content && content.includes('Flask(__name__)')) {
return true;
}
}
return false;
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Blueprint references
if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
}
return null;
},
extractNodes(filePath: string, content: string): Node[] {
const nodes: Node[] = [];
const now = Date.now();
// Extract Flask route decorators
// @app.route('/path') or @blueprint.route('/path')
const routePattern = /@(\w+)\.route\s*\(\s*['"]([^'"]+)['"]/g;
let match;
while ((match = routePattern.exec(content)) !== null) {
const [, _appOrBp, routePath] = match;
const line = content.slice(0, match.index).split('\n').length;
nodes.push({
id: `route:${filePath}:${routePath}:${line}`,
const routeNode: Node = {
id: `route:${filePath}:${line}:${urlPath}`,
kind: 'route',
name: `${routePath}`,
qualifiedName: `${filePath}::route:${routePath}`,
name: urlPath!,
qualifiedName: `${filePath}::route:${urlPath}`,
filePath,
startLine: line,
endLine: line,
@@ -179,101 +68,195 @@ export const flaskResolver: FrameworkResolver = {
endColumn: match[0].length,
language: 'python',
updatedAt: now,
});
};
nodes.push(routeNode);
const handler = handlerExpr!.trim();
const target = resolveHandlerName(handler);
if (target) {
references.push({
fromNodeId: routeNode.id,
referenceName: target.name,
referenceKind: target.kind,
line,
column: 0,
filePath,
language: 'python',
});
}
}
return nodes;
return { nodes, references };
},
};
/**
* Parse a Django URL handler expression and return the symbol/module to link.
* Returns null for shapes we can't confidently link (e.g. lambdas).
*/
function resolveHandlerName(expr: string): { name: string; kind: 'references' | 'imports' } | null {
// include('module.path')
const includeMatch = expr.match(/^include\s*\(\s*['"]([^'"]+)['"]/);
if (includeMatch) return { name: includeMatch[1]!, kind: 'imports' };
// Strip trailing .as_view(...) or .as_view()
let head = expr.replace(/\.as_view\s*\([^)]*\)\s*$/, '');
// Drop any other trailing method call
head = head.replace(/\.\w+\s*\([^)]*\)\s*$/, '');
const dotted = head.split('.').filter(Boolean);
if (dotted.length === 0) return null;
const last = dotted[dotted.length - 1]!;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(last)) return null;
return { name: last, kind: 'references' };
}
export const flaskResolver: FrameworkResolver = {
name: 'flask',
languages: ['python'],
detect(context) {
const requirements = context.readFile('requirements.txt');
if (requirements && /\bflask\b/i.test(requirements)) return true;
const pyproject = context.readFile('pyproject.toml');
if (pyproject && /\bflask\b/i.test(pyproject)) return true;
for (const file of ['app.py', 'application.py', 'main.py', '__init__.py']) {
const content = context.readFile(file);
if (content && content.includes('Flask(__name__)')) return true;
}
return false;
},
resolve(ref, context) {
if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
return null;
},
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
// Flask: @x.route('/path', methods=[...])
decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)\s*\n\s*(?:async\s+)?def\s+(\w+)/g,
defaultMethod: 'GET',
methodFromGroup: 3,
pathGroup: 2,
handlerGroup: 4,
language: 'python',
});
},
};
export const fastapiResolver: FrameworkResolver = {
name: 'fastapi',
languages: ['python'],
detect(context: ResolutionContext): boolean {
detect(context) {
const requirements = context.readFile('requirements.txt');
if (requirements && requirements.includes('fastapi')) {
return true;
}
if (requirements && /\bfastapi\b/i.test(requirements)) return true;
const pyproject = context.readFile('pyproject.toml');
if (pyproject && pyproject.includes('fastapi')) {
return true;
}
// Check for FastAPI app pattern
const appFiles = ['app.py', 'main.py', 'api.py'];
for (const file of appFiles) {
if (pyproject && /\bfastapi\b/i.test(pyproject)) return true;
for (const file of ['app.py', 'main.py', 'api.py']) {
const content = context.readFile(file);
if (content && content.includes('FastAPI()')) {
return true;
}
if (content && content.includes('FastAPI(')) return true;
}
return false;
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Router references
resolve(ref, context) {
if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, ROUTER_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
// Pattern 2: Dependency references
if (ref.referenceName.startsWith('get_') || ref.referenceName.startsWith('Depends')) {
const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, DEP_DIRS, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.75,
resolvedBy: 'framework',
};
}
if (result) return { original: ref, targetNodeId: result, confidence: 0.75, resolvedBy: 'framework' };
}
return null;
},
extractNodes(filePath: string, content: string): Node[] {
const nodes: Node[] = [];
const now = Date.now();
// Extract FastAPI route decorators
// @app.get('/path') or @router.post('/path')
const routePattern = /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]+)['"]/g;
let match;
while ((match = routePattern.exec(content)) !== null) {
const [, _appOrRouter, method, routePath] = match;
const line = content.slice(0, match.index).split('\n').length;
nodes.push({
id: `route:${filePath}:${method!.toUpperCase()}:${routePath}:${line}`,
kind: 'route',
name: `${method!.toUpperCase()} ${routePath}`,
qualifiedName: `${filePath}::${method!.toUpperCase()}:${routePath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: 'python',
updatedAt: now,
});
}
return nodes;
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
// FastAPI: @x.METHOD('/path') -> handler on the next def line
decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]+)['"]/g,
defaultMethod: '',
methodGroup: 2,
pathGroup: 3,
findHandler: true,
language: 'python',
});
},
};
interface DecoratorRouteOpts {
decoratorRegex: RegExp;
defaultMethod: string;
methodGroup?: number;
methodFromGroup?: number; // methods=[...] list
pathGroup: number;
handlerGroup?: number;
findHandler?: boolean;
language: 'python';
}
function extractDecoratorRoutes(filePath: string, content: string, opts: DecoratorRouteOpts): FrameworkExtractionResult {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
let match: RegExpExecArray | null;
while ((match = opts.decoratorRegex.exec(content)) !== null) {
const routePath = match[opts.pathGroup];
let method = opts.defaultMethod;
if (opts.methodGroup && match[opts.methodGroup]) {
method = match[opts.methodGroup]!.toUpperCase();
} else if (opts.methodFromGroup && match[opts.methodFromGroup]) {
const m = match[opts.methodFromGroup]!.match(/['"]([A-Z]+)['"]/i);
if (m) method = m[1]!.toUpperCase();
}
const line = content.slice(0, match.index).split('\n').length;
const name = method ? `${method} ${routePath}` : routePath!;
const routeNode: Node = {
id: `route:${filePath}:${line}:${method}:${routePath}`,
kind: 'route',
name,
qualifiedName: `${filePath}::${method}:${routePath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: opts.language,
updatedAt: now,
};
nodes.push(routeNode);
let handlerName: string | undefined;
if (opts.handlerGroup && match[opts.handlerGroup]) {
handlerName = match[opts.handlerGroup];
} else if (opts.findHandler) {
const tail = content.slice(match.index + match[0].length);
const defMatch = tail.match(/\n\s*(?:async\s+)?def\s+(\w+)/);
if (defMatch) handlerName = defMatch[1];
}
if (handlerName) {
references.push({
fromNodeId: routeNode.id,
referenceName: handlerName,
referenceKind: 'references',
line,
column: 0,
filePath,
language: 'python',
});
}
}
return { nodes, references };
}
// Directory patterns
const MODEL_DIRS = ['models', 'app/models', 'src/models'];
const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];