From 4afdcaae9ecd47466fd1ea0df6570b40bc91e170 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 08:49:29 -0600 Subject: [PATCH 01/18] docs: add framework extract wiring plan --- .../2026-04-24-framework-resolver-extract.md | 1117 +++++++++++++++++ 1 file changed, 1117 insertions(+) create mode 100644 docs/plans/2026-04-24-framework-resolver-extract.md diff --git a/docs/plans/2026-04-24-framework-resolver-extract.md b/docs/plans/2026-04-24-framework-resolver-extract.md new file mode 100644 index 00000000..8a5c6512 --- /dev/null +++ b/docs/plans/2026-04-24-framework-resolver-extract.md @@ -0,0 +1,1117 @@ +# Framework Resolver `extract()` Wiring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire up the dead `FrameworkResolver.extractNodes` hook so every framework resolver can contribute route nodes AND route-to-handler edges to the graph, and update all 13 existing framework resolvers to use it correctly. + +**Architecture:** Replace the unused `extractNodes?(filePath, content): Node[]` hook with a single `extract?(filePath, content): { nodes, references }` method. Call it once per file during the extraction phase (after tree-sitter parses the file) for any framework whose language matches the file. Extracted nodes go into the DB alongside tree-sitter nodes; extracted references flow into the existing unresolved-references pipeline so the existing name-matcher / import-resolver / framework `resolve()` machinery creates the final edges. Net effect: `path('/users', UserListView.as_view())` produces a `route` node linked by a `references` edge to the `UserListView` class node — and the equivalent holds for Flask, FastAPI, Express, Rails, Laravel, Spring, Gin, Axum, ASP.NET, Vapor, React Router, and SvelteKit. + +**Tech Stack:** TypeScript, vitest, tree-sitter (existing), better-sqlite3 (existing). No new dependencies. + +--- + +## Background + +Today, every `FrameworkResolver` ships with an `extractNodes?(filePath, content)` method (express, laravel, python/django, python/flask, python/fastapi, ruby/rails, java/spring, go, rust, csharp, swift × 3, react, svelte). None of them are ever called. Empirical proof: grep across `src/` finds exactly one reference to `extractNodes` — the interface definition at `src/resolution/types.ts:99`. As a result the graph has zero `route` kind nodes in practice, and the link between a URL entry in a routing file and its view/controller/handler doesn't exist. + +Separately, the Django extractor's regex captures the view name in group 2 but the destructure in `src/resolution/frameworks/python.ts` discards it, so even if the hook were alive it wouldn't link the route to the view. Similar shape bugs exist across most frameworks. + +This plan fixes both problems in one coherent change. + +## File Structure + +- `src/resolution/types.ts` — add `extract?()` to `FrameworkResolver`; remove `extractNodes?()`. +- `src/resolution/frameworks/index.ts` — keep `detectFrameworks` signature; add `getApplicableFrameworks(language)` helper. +- `src/resolution/frameworks/python.ts` — rewrite Django/Flask/FastAPI extractors. +- `src/resolution/frameworks/express.ts` / `laravel.ts` / `ruby.ts` / `java.ts` / `go.ts` / `rust.ts` / `csharp.ts` / `swift.ts` / `react.ts` / `svelte.ts` — migrate to new interface. +- `src/extraction/index.ts` — plug framework extraction into `ExtractionOrchestrator.indexAll` after per-file tree-sitter parse. +- `src/extraction/parse-worker.ts` — pass detected-framework names into the worker so the worker can invoke framework extractors itself (needed because main-thread `extractFromSource` and worker-thread parse path both have to cover this). +- `__tests__/frameworks.test.ts` — NEW. One `describe` per framework, checking that representative fixtures produce the expected `{nodes, references}`. +- `__tests__/frameworks-integration.test.ts` — NEW. End-to-end test: index a tiny Django project fixture, assert a `route -> class` edge with kind `references` exists from `urlpatterns` entry to `UserListView`. + +Rationale for splitting the two test files: the unit tests are deterministic string-in / array-out and run in milliseconds; the integration test boots a CodeGraph DB and is slower but gives the strongest behavioral guarantee. + +## Scope Note + +This plan does NOT move Django extraction from regex to AST. The regex approach is fine for the shapes this PR targets (`path(...)`, `url(...)`, `re_path(...)`, `include(...)`, DRF `router.register(...)`, CBV `.as_view()`, dotted module paths). A follow-up PR can swap the regex for AST walking using tree-sitter's existing Python parser. That's a larger change and doesn't block this one. + +--- + +## Task 1: Update the `FrameworkResolver` interface + +**Files:** +- Modify: `src/resolution/types.ts:88-100` + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/frameworks.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +describe('FrameworkResolver.extract interface', () => { + it('extract() returns { nodes, references }', () => { + const resolver: FrameworkResolver = { + name: 'fake', + detect: () => true, + resolve: () => null, + languages: ['python'], + extract: (_filePath: string, _content: string) => ({ + nodes: [] as Node[], + references: [] as UnresolvedRef[], + }), + }; + const result = resolver.extract!('foo.py', ''); + expect(result).toEqual({ nodes: [], references: [] }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run __tests__/frameworks.test.ts` +Expected: FAIL — `extract` is not a property of `FrameworkResolver`; `languages` is not a property of `FrameworkResolver`. + +- [ ] **Step 3: Update the interface** + +Replace `src/resolution/types.ts:88-100` with: + +```typescript +/** + * Result of framework-specific file extraction. + */ +export interface FrameworkExtractionResult { + /** Framework-specific nodes (e.g. routes) */ + nodes: Node[]; + /** Framework-specific unresolved references (e.g. route -> handler) */ + references: UnresolvedRef[]; +} + +/** + * Framework-specific resolver + */ +export interface FrameworkResolver { + /** Framework name */ + name: string; + /** Languages this framework applies to. If omitted, applies to all languages. */ + languages?: Language[]; + /** Detect if project uses this framework (project-level, called once at startup) */ + detect(context: ResolutionContext): boolean; + /** Resolve a reference using framework-specific patterns */ + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null; + /** + * Extract framework-specific nodes and references from a file. + * + * Returns route nodes, middleware nodes, etc., plus unresolved references + * that link those nodes to handlers (view classes, controller methods, + * included modules). Unresolved references flow into the normal resolution + * pipeline; the framework's own `resolve()` is one of the strategies tried. + */ + extract?(filePath: string, content: string): FrameworkExtractionResult; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run __tests__/frameworks.test.ts` +Expected: PASS. + +- [ ] **Step 5: Run typecheck to catch downstream breakage** + +Run: `npx tsc --noEmit` +Expected: FAIL — every `src/resolution/frameworks/*.ts` will error on `extractNodes` not existing on `FrameworkResolver`. That's expected; subsequent tasks fix each one. + +- [ ] **Step 6: Commit** + +```bash +git add src/resolution/types.ts __tests__/frameworks.test.ts +git commit -m "feat(resolution): replace extractNodes with extract() returning nodes and references" +``` + +--- + +## Task 2: Add `getApplicableFrameworks` helper and keep detection correct + +**Files:** +- Modify: `src/resolution/frameworks/index.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `__tests__/frameworks.test.ts`: + +```typescript +import { getApplicableFrameworks } from '../src/resolution/frameworks'; +import type { FrameworkResolver } from '../src/resolution/types'; + +describe('getApplicableFrameworks', () => { + const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null }; + const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null }; + const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null }; + + it('filters by language', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python'); + expect(result.map(r => r.name)).toEqual(['py', 'any']); + }); + + it('returns anyFw-only when language has no matches', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust'); + expect(result.map(r => r.name)).toEqual(['any']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run __tests__/frameworks.test.ts` +Expected: FAIL — `getApplicableFrameworks` is not exported. + +- [ ] **Step 3: Add helper to `src/resolution/frameworks/index.ts`** + +Add after the existing `detectFrameworks` function: + +```typescript +import type { Language } from '../../types'; + +/** + * Filter a list of detected frameworks down to ones that apply to a given language. + * Frameworks without an explicit `languages` list are treated as universal. + */ +export function getApplicableFrameworks( + detected: FrameworkResolver[], + language: Language +): FrameworkResolver[] { + return detected.filter( + (fw) => !fw.languages || fw.languages.includes(language) + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run __tests__/frameworks.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/resolution/frameworks/index.ts __tests__/frameworks.test.ts +git commit -m "feat(resolution): add getApplicableFrameworks helper for per-language dispatch" +``` + +--- + +## Task 3: Port Django resolver to new `extract()` with proper route→view references + +**Files:** +- Modify: `src/resolution/frameworks/python.ts` (djangoResolver section, ~line 1-100) + +- [ ] **Step 1: Write the failing tests** + +Append to `__tests__/frameworks.test.ts`: + +```typescript +import { djangoResolver } from '../src/resolution/frameworks/python'; + +describe('djangoResolver.extract', () => { + it('extracts route node and reference for path() with CBV.as_view()', () => { + const src = ` +from django.urls import path +from users.views import UserListView + +urlpatterns = [ + path('users/', UserListView.as_view(), name='user-list'), +] +`; + const { nodes, references } = djangoResolver.extract!('users/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('users/'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('extracts route for path() with dotted module.Class.as_view()', () => { + const src = `from django.urls import path\nfrom api.v1 import views as api_v1_views\nurlpatterns = [path('api/', api_v1_views.UserListView.as_view())]\n`; + const { nodes, references } = djangoResolver.extract!('api/urls.py', src); + expect(nodes).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + }); + + it('extracts route for path() with bare function view', () => { + const src = `from django.urls import path\nurlpatterns = [path('home/', home_view, name='home')]\n`; + const { nodes, references } = djangoResolver.extract!('home/urls.py', src); + expect(references[0].referenceName).toBe('home_view'); + }); + + it('extracts route for path() with include()', () => { + const src = `from django.urls import path, include\nurlpatterns = [path('api/', include('api.urls'))]\n`; + const { nodes, references } = djangoResolver.extract!('root/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(references[0].referenceName).toBe('api.urls'); + expect(references[0].referenceKind).toBe('imports'); + }); + + it('extracts routes for re_path and url', () => { + const src = `from django.urls import re_path, url\nurlpatterns = [re_path(r'^users/$', UserView), url(r'^old/$', OldView)]\n`; + const { nodes } = djangoResolver.extract!('legacy/urls.py', src); + expect(nodes).toHaveLength(2); + expect(nodes.map(n => n.name)).toEqual(['^users/$', '^old/$']); + }); + + it('returns empty result for a non-urls.py python file', () => { + const src = `def foo(): return 1\n`; + const { nodes, references } = djangoResolver.extract!('views.py', src); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run __tests__/frameworks.test.ts -t djangoResolver` +Expected: FAIL — `djangoResolver.extract` is undefined. + +- [ ] **Step 3: Rewrite djangoResolver** + +Replace the `djangoResolver` object in `src/resolution/frameworks/python.ts` (approximately lines 7-100) with: + +```typescript +export const djangoResolver: FrameworkResolver = { + name: 'django', + languages: ['python'], + + detect(context) { + const requirements = context.readFile('requirements.txt'); + if (requirements && requirements.toLowerCase().includes('django')) return true; + const setup = context.readFile('setup.py'); + if (setup && setup.toLowerCase().includes('django')) return true; + const pyproject = context.readFile('pyproject.toml'); + if (pyproject && pyproject.toLowerCase().includes('django')) return true; + return context.fileExists('manage.py'); + }, + + 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 (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 (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' }; + } + return null; + }, + + extract(filePath, content) { + if (!filePath.endsWith('.py')) return { nodes: [], references: [] }; + + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + + // path('url', handler, name=...) / re_path(r'...', handler) / url(r'...', handler) + // Capture groups: 1=function name, 2=url string, 3=rest of line up to closing ) + const routeRegex = /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([^)]*?)(?:\)|,\s*name=)/g; + + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, _fn, urlPath, handlerExpr] = match; + const line = content.slice(0, match.index).split('\n').length; + + const routeNode: Node = { + id: `route:${filePath}:${line}:${urlPath}`, + kind: 'route', + name: urlPath, + qualifiedName: `${filePath}::route:${urlPath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + 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, 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') / 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 call + let head = expr.replace(/\.as_view\s*\([^)]*\)\s*$/, ''); + + // Drop a trailing method call like .some_method() + head = head.replace(/\.\w+\s*\([^)]*\)\s*$/, ''); + + // Now head should be either a bare name or a dotted path. Take the last segment. + 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' }; +} +``` + +Also ensure the top of the file imports `UnresolvedRef` and `Node`: + +```typescript +import type { FrameworkResolver, UnresolvedRef } from '../types'; +import type { Node } from '../../types'; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run __tests__/frameworks.test.ts -t djangoResolver` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/resolution/frameworks/python.ts __tests__/frameworks.test.ts +git commit -m "feat(django): emit route nodes and route->view references in extract()" +``` + +--- + +## Task 4: Port Flask and FastAPI resolvers + +**Files:** +- Modify: `src/resolution/frameworks/python.ts` (flaskResolver and fastapiResolver sections) + +- [ ] **Step 1: Write the failing tests** + +Append to `__tests__/frameworks.test.ts`: + +```typescript +import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python'; + +describe('flaskResolver.extract', () => { + it('extracts route and reference from @app.route', () => { + const src = ` +@app.route('/users') +def list_users(): + return [] +`; + const { nodes, references } = flaskResolver.extract!('app.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts blueprint routes', () => { + const src = ` +@users_bp.route('/', methods=['POST']) +def create_user(id): + pass +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create_user'); + }); +}); + +describe('fastapiResolver.extract', () => { + it('extracts route and reference from @app.get', () => { + const src = ` +@app.get('/users') +async def list_users(): + return [] +`; + const { nodes, references } = fastapiResolver.extract!('main.py', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts route from router.post', () => { + const src = ` +@router.post('/items') +def create_item(item: Item): + pass +`; + const { nodes, references } = fastapiResolver.extract!('items.py', src); + expect(nodes[0].name).toBe('POST /items'); + expect(references[0].referenceName).toBe('create_item'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run __tests__/frameworks.test.ts -t "flaskResolver|fastapiResolver"` +Expected: FAIL — both resolvers' `extract` are undefined. + +- [ ] **Step 3: Rewrite flaskResolver and fastapiResolver** + +Replace `flaskResolver` in `src/resolution/frameworks/python.ts` with: + +```typescript +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, content, { + // 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) { + const requirements = context.readFile('requirements.txt'); + if (requirements && /\bfastapi\b/i.test(requirements)) return true; + const pyproject = context.readFile('pyproject.toml'); + 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; + } + return false; + }, + + 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 (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' }; + } + return null; + }, + + extract(filePath, content) { + if (!filePath.endsWith('.py')) return { nodes: [], references: [] }; + return extractDecoratorRoutes(filePath, content, { + // FastAPI: @x.get('/path') + decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]+)['"]/g, + defaultMethod: '', + methodGroup: 2, + pathGroup: 3, + // handler follows on next def line; captured via post-scan + handlerGroup: undefined, + findHandler: true, + language: 'python', + }); + }, +}; +``` + +And add this shared helper at the bottom of `python.ts`: + +```typescript +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) { + 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) { + // Find the next `def ` after the decorator + 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 }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run __tests__/frameworks.test.ts -t "flaskResolver|fastapiResolver"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/resolution/frameworks/python.ts __tests__/frameworks.test.ts +git commit -m "feat(flask,fastapi): emit route nodes and route->handler references" +``` + +--- + +## Task 5: Port Express resolver + +**Files:** +- Modify: `src/resolution/frameworks/express.ts` (extractNodes section, ~line 83-117) + +- [ ] **Step 1: Write failing tests** + +Append to `__tests__/frameworks.test.ts`: + +```typescript +import { expressResolver } from '../src/resolution/frameworks/express'; + +describe('expressResolver.extract', () => { + it('extracts route with inline handler reference', () => { + const src = `app.get('/users', listUsers);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts route with router.post', () => { + const src = `router.post('/items', auth, createItem);\n`; + const { nodes, references } = expressResolver.extract!('items.ts', src); + expect(nodes[0].name).toBe('POST /items'); + // Multiple handlers: prefer the LAST one (convention: middleware comes first, handler last) + expect(references[0].referenceName).toBe('createItem'); + }); + + it('extracts route with controller method reference', () => { + const src = `app.get('/x', userController.list);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(references[0].referenceName).toBe('list'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run __tests__/frameworks.test.ts -t expressResolver` +Expected: FAIL. + +- [ ] **Step 3: Rewrite expressResolver.extract** + +Replace the existing `extractNodes` method on `expressResolver` (in `src/resolution/frameworks/express.ts`) with: + +```typescript + languages: ['javascript', 'typescript'], + + extract(filePath, content) { + if (!/\.(m?js|tsx?|cjs)$/.test(filePath)) return { nodes: [], references: [] }; + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + // Capture: (app|router).METHOD('/path', handler-expr) + const regex = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + const [, _obj, method, routePath, handlers] = match; + if (method === 'use' && !routePath.startsWith('/')) continue; + const line = content.slice(0, match.index).split('\n').length; + const routeNode: Node = { + id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`, + kind: 'route', + name: `${method.toUpperCase()} ${routePath}`, + qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: detectLanguage(filePath), + updatedAt: now, + }; + nodes.push(routeNode); + // Last comma-separated arg is the handler; intermediate args are middleware + const handlerParts = handlers.split(',').map((s) => s.trim()).filter(Boolean); + const last = handlerParts[handlerParts.length - 1]; + const handlerName = extractTailIdent(last); + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: detectLanguage(filePath), + }); + } + } + return { nodes, references }; + }, +``` + +And add near the top of the file: + +```typescript +import type { FrameworkResolver, UnresolvedRef } from '../types'; +import type { Node } from '../../types'; + +function extractTailIdent(expr: string): string | null { + const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, ''); + const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/); + return m ? m[1] : null; +} +``` + +Remove the old `extractNodes` method. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run __tests__/frameworks.test.ts -t expressResolver` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/resolution/frameworks/express.ts __tests__/frameworks.test.ts +git commit -m "feat(express): emit route nodes and route->handler references" +``` + +--- + +## Task 6: Port Laravel, Rails, Spring, Gin (Go), Axum (Rust), ASP.NET (C#), Swift resolvers + +**Files:** +- Modify: `src/resolution/frameworks/laravel.ts` / `ruby.ts` / `java.ts` / `go.ts` / `rust.ts` / `csharp.ts` / `swift.ts` + +Each framework follows the **same pattern** as Tasks 3–5 above: + +1. Add `languages: [...]` field. +2. Replace `extractNodes(filePath, content)` with `extract(filePath, content): { nodes, references }`. +3. Inside `extract()`, for each matched route regex: create a route node (reuse existing shape) AND emit a `UnresolvedRef` for the handler/controller with `fromNodeId = routeNode.id`. +4. For each framework, add one unit test to `__tests__/frameworks.test.ts` that verifies at least one route shape produces both a node and a handler reference. + +**Per-framework specifics:** + +- **Laravel** (`laravel.ts`): `Route::get('/x', [Ctrl::class, 'method'])` → handler ref name = `method`; `Route::get('/x', 'Ctrl@method')` → handler ref name = `method`; `Route::resource('users', UserController::class)` → handler ref name = `UserController`. `languages: ['php']`. + +- **Rails** (`ruby.ts`): `get '/x', to: 'users#index'` → handler ref name = `index` (scope by `users`); `resources :users` → one node per CRUD action, each referencing the corresponding method name on `UsersController`. `languages: ['ruby']`. + +- **Spring** (`java.ts`): `@GetMapping("/x")` on method → handler is the following method name (scan forward past the decorator). `languages: ['java']`. + +- **Gin / chi / gorilla** (`go.ts`): `r.GET("/x", handler)` → handler ref = last ident in the last arg. `languages: ['go']`. + +- **Axum / actix** (`rust.ts`): `.route("/x", get(handler))` → handler ref = ident inside `get(...)`. `languages: ['rust']`. + +- **ASP.NET** (`csharp.ts`): `[HttpGet("/x")] public ActionResult Method()` → handler ref = method name on same class. `languages: ['csharp']`. + +- **Swift / Vapor** (`swift.ts`): `app.get("/x", use: handler)` → handler ref = ident after `use:`. `languages: ['swift']`. + +Each of these gets its own commit in the form: + +```bash +git add src/resolution/frameworks/.ts __tests__/frameworks.test.ts +git commit -m "feat(): emit route nodes and route->handler references" +``` + +**Important:** keep each framework's commit independent so any one of them can be reverted if it causes regressions. + +### Task 6a: Laravel + +- [ ] **Step 1: Write test** for `Route::get('/users', [UserController::class, 'index'])` → `{nodes[0].name='GET /users', references[0].referenceName='index'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`** following the Express pattern. Regex: `/Route::(get|post|put|patch|delete|options|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g`. Extract handler from third group via `resolveLaravelHandler()`: strip `[`/`]`/`::class`, take second element of comma-split array or `Ctrl@method`. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6b: Rails + +- [ ] **Step 1: Write test** for `get '/users', to: 'users#index'` → `{references[0].referenceName='index'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`**. Regex: `/\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*,\s*to:\s*['"]([^'"]+)['"]/g` → `controller#method` split on `#` gives handler = `method`. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6c: Spring + +- [ ] **Step 1: Write test** for `@GetMapping("/x")\npublic String list() {...}` → `{references[0].referenceName='list'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`** using the shared `extractDecoratorRoutes` helper (move it to a new `src/resolution/frameworks/shared.ts` if cleaner). Find the next `public` or `private` method declaration's name after each mapping annotation. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6d: Go + +- [ ] **Step 1: Write test** for `r.GET("/x", handler)` and `router.Handle("/x", handler)` → `{references[0].referenceName='handler'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`**. Regex: `/\b(?:router|r|mux|app)\.(GET|POST|PUT|PATCH|DELETE|Handle|HandleFunc)\s*\(\s*["]([^"]+)["]\s*,\s*([^)]+)\)/g`. Handler = last ident in third group. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6e: Rust + +- [ ] **Step 1: Write test** for `.route("/x", get(list_users))` → `{references[0].referenceName='list_users'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`**. Regex: `/\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g` → handler = group 3. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6f: C# (ASP.NET) + +- [ ] **Step 1: Write test** for `[HttpGet("/x")]\npublic IActionResult List()` → `{references[0].referenceName='List'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`**. Find attributes, then scan forward to first `public|private|protected` method declaration and take its name. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6g: Swift / Vapor + +- [ ] **Step 1: Write test** for `app.get("/users", use: list)` → `{references[0].referenceName='list'}`. +- [ ] **Step 2: Run test, see fail.** +- [ ] **Step 3: Implement `extract()`**. Regex: `/\b(app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g` → handler = group 4's last segment. +- [ ] **Step 4: Run test, see pass.** +- [ ] **Step 5: Commit.** + +### Task 6h: React & Svelte + +These are UI frameworks where routes map to components, not handlers in the server sense. Keep the existing behavior but migrate the interface: + +- [ ] **Step 1: Migrate `reactResolver`** (`src/resolution/frameworks/react.ts`) — add `languages: ['javascript', 'typescript']`, rename `extractNodes` to `extract`, make it return `{ nodes, references: [] }` (the existing logic only emits nodes, no handler references needed yet — a follow-up can add `}/>` → `Page` references). +- [ ] **Step 2: Migrate `svelteResolver`** (`src/resolution/frameworks/svelte.ts`) — same pattern; `languages: ['svelte']`. +- [ ] **Step 3: Add a smoke test** for each that verifies `extract()` returns the same node shape it used to. +- [ ] **Step 4: Run tests, see pass.** +- [ ] **Step 5: Commit.** + +--- + +## Task 7: Wire framework extraction into `ExtractionOrchestrator` + +**Files:** +- Modify: `src/extraction/index.ts` (the per-file extraction result merging path) +- Modify: `src/extraction/parse-worker.ts` (pass detected frameworks to worker if extraction runs there) + +This is the core wiring change. It runs after each file is parsed by tree-sitter. + +- [ ] **Step 1: Write an integration test** + +Create `__tests__/frameworks-integration.test.ts`: + +```typescript +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Django end-to-end', () => { + let tmpDir: string; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a route->view edge from urls.py to view class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-django-')); + fs.writeFileSync(path.join(tmpDir, 'manage.py'), '# marker'); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'django==4.2\n'); + fs.mkdirSync(path.join(tmpDir, 'users')); + fs.writeFileSync(path.join(tmpDir, 'users/__init__.py'), ''); + fs.writeFileSync(path.join(tmpDir, 'users/views.py'), + 'class UserListView:\n def get(self, request): pass\n'); + fs.writeFileSync(path.join(tmpDir, 'users/urls.py'), + 'from django.urls import path\n' + + 'from users.views import UserListView\n' + + 'urlpatterns = [path("users/", UserListView.as_view(), name="user-list")]\n'); + + const cg = new CodeGraph(tmpDir); + await cg.initialize(); + await cg.indexAll(); + + const nodes = cg.queries.searchNodes({ kinds: ['route'] }); + expect(nodes.length).toBeGreaterThan(0); + const route = nodes.find(n => n.name === 'users/'); + expect(route).toBeDefined(); + + const view = cg.queries.getNodesByName('UserListView').find(n => n.kind === 'class'); + expect(view).toBeDefined(); + + const edges = cg.queries.getOutgoingEdges(route!.id); + const toView = edges.find(e => e.target === view!.id); + expect(toView).toBeDefined(); + expect(toView!.kind).toBe('references'); + + await cg.close(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run __tests__/frameworks-integration.test.ts` +Expected: FAIL — no route nodes get created (framework extract isn't wired in yet). + +- [ ] **Step 3: Add the wiring** + +In `src/extraction/index.ts`, locate the `extractFromSource` function (around line 600; the function that runs tree-sitter on a single file and returns `ExtractionResult`). Add framework extraction as a post-tree-sitter augmentation. + +Find where `ExtractionResult` is built at the end of `extractFromSource` (around line 1000-1015). Just before `return result`, add: + +```typescript +// Framework-specific extraction (routes, etc.) +if (detectedFrameworks && detectedFrameworks.length > 0) { + const applicable = getApplicableFrameworks(detectedFrameworks, language); + for (const fw of applicable) { + if (!fw.extract) continue; + try { + const fwResult = fw.extract(filePath, content); + result.nodes.push(...fwResult.nodes); + result.unresolvedReferences.push(...fwResult.references); + } catch (err) { + result.errors.push({ + message: `Framework extractor '${fw.name}' failed: ${err instanceof Error ? err.message : String(err)}`, + filePath, + severity: 'warning', + }); + } + } +} +``` + +Also add `detectedFrameworks?: FrameworkResolver[]` as a parameter to `extractFromSource`. + +In `ExtractionOrchestrator.indexAll` (around line 412), before kicking off the parse workers, detect frameworks once: + +```typescript +// Detect frameworks once per indexing run (project-level signal) +const resolutionContext = buildResolutionContext(this.rootDir, this.queries); +const detectedFrameworks = detectFrameworks(resolutionContext); +``` + +Pass `detectedFrameworks` into the parse worker batch config (or, if the parse worker doesn't invoke `extractFromSource` directly, into the main-thread merge step that invokes framework extract on the raw file content). If the parse worker already has access to file content, pass the framework NAMES and re-resolve to resolver objects inside the worker from `getAllFrameworkResolvers().filter(f => detectedNames.includes(f.name))` — objects with functions can't cross worker_threads postMessage boundaries. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run __tests__/frameworks-integration.test.ts` +Expected: PASS. + +- [ ] **Step 5: Run the full test suite to check for regressions** + +Run: `npx vitest run` +Expected: All existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/extraction/index.ts src/extraction/parse-worker.ts __tests__/frameworks-integration.test.ts +git commit -m "feat(extraction): run framework extractors after tree-sitter parse" +``` + +--- + +## Task 8: Remove dead regex code + update README + +**Files:** +- Modify: `src/resolution/frameworks/*.ts` — confirm no dangling `extractNodes` remains +- Modify: `README.md` — add a section on framework route extraction + +- [ ] **Step 1: grep for any lingering references** + +Run: `grep -rn "extractNodes" src/ __tests__/` +Expected: zero matches. If any remain, delete or rename them. + +- [ ] **Step 2: Run the full build and test** + +Run: `npm run build && npm test` +Expected: Build succeeds; all tests pass. + +- [ ] **Step 3: Add a README section** + +Append to `README.md` after the features list: + +```markdown +### Framework-aware Routes + +CodeGraph recognizes web framework routing files and links URL patterns to their handlers: + +- **Django**: `urlpatterns` entries in `urls.py` — `path()`, `re_path()`, `url()`, `include()` +- **Flask / FastAPI**: `@app.route` / `@app.get` / `@router.post` decorators +- **Express**: `app.get(...)`, `router.post(...)` +- **Laravel**: `Route::get()`, `Route::resource()` +- **Rails**: `resources :users`, `get 'x', to: 'y#z'` +- **Spring**: `@GetMapping`, `@RequestMapping` +- **Gin / chi / gorilla**: `r.GET(...)` +- **Axum / actix**: `.route("/x", get(handler))` +- **ASP.NET**: `[HttpGet]` + action method +- **Vapor**: `app.get("x", use: handler)` + +Query `codegraph_callers(YourView)` and the route pattern will appear as an incoming edge. +``` + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: document framework route extraction" +``` + +--- + +## Task 9: Open the PR + +- [ ] **Step 1: Push branch to fork** + +```bash +git push -u origin feat/framework-extract-wiring +``` + +- [ ] **Step 2: Create PR** + +```bash +gh pr create \ + --repo colbymchenry/codegraph \ + --base main \ + --head timomeara:feat/framework-extract-wiring \ + --title "feat: wire up framework route extraction" \ + --body "$(cat <<'EOF' +## Problem + +`FrameworkResolver.extractNodes` is declared in the type but never called anywhere in `src/`. As a result, the graph has zero `route` nodes for any framework, and the URL-to-handler link (e.g. Django `urls.py` entry -> view class) doesn't exist. This makes `codegraph_callers(MyView)` silently miss its most important caller. + +## Fix + +- Replaces the dead `extractNodes?(filePath, content): Node[]` hook with `extract?(filePath, content): { nodes, references }`. +- Calls `extract()` inside the extraction pipeline for every framework whose declared `languages` include the current file's language. +- Updates all 13 existing framework resolvers (Django, Flask, FastAPI, Express, Laravel, Rails, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit) to emit both route nodes AND handler references. The references flow through the existing resolution pipeline (name matching, import resolution, framework-specific `resolve()`) to produce `route -> handler` edges. + +## Tests + +- Unit tests per framework in `__tests__/frameworks.test.ts`. +- End-to-end Django test in `__tests__/frameworks-integration.test.ts` that verifies a real `urls.py -> views.py` edge. + +## Stats + +| Category | Lines | +|----------|------:| +| Production code | ~X | +| Tests | ~Y | +| Docs | ~Z | +EOF +)" +``` + +- [ ] **Step 3: Link PR in task tracker** (if one exists). + +--- + +## Self-Review Checklist + +- [ ] **Spec coverage:** each framework in the original codebase has a migration task. Django has the richest test coverage because it was the motivating case. +- [ ] **No placeholders:** every task shows actual code. The "same pattern as Task X" phrasing in Task 6 is backed by full implementations in Tasks 3-5 as referents. +- [ ] **Type consistency:** `FrameworkExtractionResult` is defined once in Task 1 and used by every resolver's `extract` signature. +- [ ] **Realistic stats placeholders** (X/Y/Z) are filled in at PR time, not plan time. + +## Known gaps (intentionally out of scope) + +- **AST-based extraction.** Regex is good enough for the common shapes. Swap to tree-sitter AST in a follow-up. +- **DRF router expansion.** `router.register(r'users', UserViewSet)` produces a single route node pointing at the viewset. Expanding to 6 CRUD action nodes can be a follow-up. +- **React Router handler edges.** `}/>` currently only produces a route node. Follow-up can add `route -> Page` references. +- **Spring Controller-class scoping.** Method-scoped mappings work; class-level `@RequestMapping` base path composition is a follow-up. From e4966951111d61219bceda0f3fd8dfd8cd698e80 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:06:50 -0600 Subject: [PATCH 02/18] feat(resolution): replace extractNodes with extract() returning nodes and references --- __tests__/frameworks.test.ts | 20 ++++++++++++++++++++ src/resolution/types.ts | 25 ++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 __tests__/frameworks.test.ts diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts new file mode 100644 index 00000000..240b19f4 --- /dev/null +++ b/__tests__/frameworks.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +describe('FrameworkResolver.extract interface', () => { + it('extract() returns { nodes, references }', () => { + const resolver: FrameworkResolver = { + name: 'fake', + detect: () => true, + resolve: () => null, + languages: ['python'], + extract: (_filePath: string, _content: string) => ({ + nodes: [] as Node[], + references: [] as UnresolvedRef[], + }), + }; + const result = resolver.extract!('foo.py', ''); + expect(result).toEqual({ nodes: [], references: [] }); + }); +}); diff --git a/src/resolution/types.ts b/src/resolution/types.ts index f2e9c485..e896c6eb 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -85,18 +85,37 @@ export interface ResolutionContext { getImportMappings(filePath: string, language: Language): ImportMapping[]; } +/** + * Result of framework-specific file extraction. + */ +export interface FrameworkExtractionResult { + /** Framework-specific nodes (e.g. routes) */ + nodes: Node[]; + /** Framework-specific unresolved references (e.g. route -> handler) */ + references: UnresolvedRef[]; +} + /** * Framework-specific resolver */ export interface FrameworkResolver { /** Framework name */ name: string; - /** Detect if project uses this framework */ + /** Languages this framework applies to. If omitted, applies to all languages. */ + languages?: Language[]; + /** Detect if project uses this framework (project-level, called once at startup) */ detect(context: ResolutionContext): boolean; /** Resolve a reference using framework-specific patterns */ resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null; - /** Extract additional nodes specific to this framework */ - extractNodes?(filePath: string, content: string): Node[]; + /** + * Extract framework-specific nodes and references from a file. + * + * Returns route nodes, middleware nodes, etc., plus unresolved references + * that link those nodes to handlers (view classes, controller methods, + * included modules). Unresolved references flow into the normal resolution + * pipeline; the framework's own `resolve()` is one of the strategies tried. + */ + extract?(filePath: string, content: string): FrameworkExtractionResult; } /** From 741f812247b5dcfca5d210f62303d741d81b5b37 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:14:20 -0600 Subject: [PATCH 03/18] feat(resolution): add getApplicableFrameworks helper for per-language dispatch --- __tests__/frameworks.test.ts | 19 +++++++++++++++++++ src/resolution/frameworks/index.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 240b19f4..befd091f 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -18,3 +18,22 @@ describe('FrameworkResolver.extract interface', () => { expect(result).toEqual({ nodes: [], references: [] }); }); }); + +import { getApplicableFrameworks } from '../src/resolution/frameworks'; +import type { FrameworkResolver } from '../src/resolution/types'; + +describe('getApplicableFrameworks', () => { + const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null }; + const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null }; + const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null }; + + it('filters by language', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python'); + expect(result.map(r => r.name)).toEqual(['py', 'any']); + }); + + it('returns anyFw-only when language has no matches', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust'); + expect(result.map(r => r.name)).toEqual(['any']); + }); +}); diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 830a7d62..287d7c90 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -5,6 +5,7 @@ */ import { FrameworkResolver, ResolutionContext } from '../types'; +import type { Language } from '../../types'; import { laravelResolver } from './laravel'; import { expressResolver } from './express'; import { reactResolver } from './react'; @@ -74,6 +75,19 @@ export function detectFrameworks(context: ResolutionContext): FrameworkResolver[ }); } +/** + * Filter a list of detected frameworks down to ones that apply to a given language. + * Frameworks without an explicit `languages` list are treated as universal. + */ +export function getApplicableFrameworks( + detected: FrameworkResolver[], + language: Language +): FrameworkResolver[] { + return detected.filter( + (fw) => !fw.languages || fw.languages.includes(language) + ); +} + /** * Register a custom framework resolver */ From 8b0de2ac0935511f21ee3cae1db3ba62fc57ac92 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:19:29 -0600 Subject: [PATCH 04/18] feat(django): emit route nodes and route->view references in extract() --- __tests__/frameworks.test.ts | 59 ++++++++++++ src/resolution/frameworks/python.ts | 141 ++++++++++++++-------------- 2 files changed, 129 insertions(+), 71 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index befd091f..4d8ca729 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -37,3 +37,62 @@ describe('getApplicableFrameworks', () => { expect(result.map(r => r.name)).toEqual(['any']); }); }); + +import { djangoResolver } from '../src/resolution/frameworks/python'; + +describe('djangoResolver.extract', () => { + it('extracts route node and reference for path() with CBV.as_view()', () => { + const src = ` +from django.urls import path +from users.views import UserListView + +urlpatterns = [ + path('users/', UserListView.as_view(), name='user-list'), +] +`; + const { nodes, references } = djangoResolver.extract!('users/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('users/'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('extracts route for path() with dotted module.Class.as_view()', () => { + const src = `from django.urls import path\nfrom api.v1 import views as api_v1_views\nurlpatterns = [path('api/', api_v1_views.UserListView.as_view())]\n`; + const { nodes, references } = djangoResolver.extract!('api/urls.py', src); + expect(nodes).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + }); + + it('extracts route for path() with bare function view', () => { + const src = `from django.urls import path\nurlpatterns = [path('home/', home_view, name='home')]\n`; + const { nodes, references } = djangoResolver.extract!('home/urls.py', src); + expect(references[0].referenceName).toBe('home_view'); + }); + + it('extracts route for path() with include()', () => { + const src = `from django.urls import path, include\nurlpatterns = [path('api/', include('api.urls'))]\n`; + const { nodes, references } = djangoResolver.extract!('root/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(references[0].referenceName).toBe('api.urls'); + expect(references[0].referenceKind).toBe('imports'); + }); + + it('extracts routes for re_path and url', () => { + const src = `from django.urls import re_path, url\nurlpatterns = [re_path(r'^users/$', UserView), url(r'^old/$', OldView)]\n`; + const { nodes } = djangoResolver.extract!('legacy/urls.py', src); + expect(nodes).toHaveLength(2); + expect(nodes.map(n => n.name)).toEqual(['^users/$', '^old/$']); + }); + + it('returns empty result for a non-urls.py python file', () => { + const src = `def foo(): return 1\n`; + const { nodes, references } = djangoResolver.extract!('views.py', src); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts index 88f5034a..fbb8fd54 100644 --- a/src/resolution/frameworks/python.ts +++ b/src/resolution/frameworks/python.ts @@ -9,108 +9,107 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from 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(); - // Extract URL patterns - // path('route/', view, name='name') - const urlPatterns = [ - /path\s*\(\s*['"]([^'"]+)['"],\s*(\w+)/g, - /url\s*\(\s*r?['"]([^'"]+)['"],\s*(\w+)/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; - - nodes.push({ - id: `route:${filePath}:${urlPath}:${line}`, - kind: 'route', - name: urlPath!, - qualifiedName: `${filePath}::route:${urlPath}`, + // 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; + + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, _fn, urlPath, handlerExpr] = match; + const line = content.slice(0, match.index).split('\n').length; + + const routeNode: Node = { + id: `route:${filePath}:${line}:${urlPath}`, + kind: 'route', + name: urlPath!, + qualifiedName: `${filePath}::route:${urlPath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + 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, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, language: 'python', - updatedAt: now, }); } } - 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', From f88391fdf709932d17e5bdd3345f06e1337ae029 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:28:16 -0600 Subject: [PATCH 05/18] feat(flask,fastapi): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 52 +++++++ src/resolution/frameworks/python.ts | 220 +++++++++++++--------------- 2 files changed, 153 insertions(+), 119 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 4d8ca729..8626cc7f 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -96,3 +96,55 @@ urlpatterns = [ expect(references).toEqual([]); }); }); + +import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python'; + +describe('flaskResolver.extract', () => { + it('extracts route and reference from @app.route', () => { + const src = ` +@app.route('/users') +def list_users(): + return [] +`; + const { nodes, references } = flaskResolver.extract!('app.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts blueprint routes', () => { + const src = ` +@users_bp.route('/', methods=['POST']) +def create_user(id): + pass +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create_user'); + }); +}); + +describe('fastapiResolver.extract', () => { + it('extracts route and reference from @app.get', () => { + const src = ` +@app.get('/users') +async def list_users(): + return [] +`; + const { nodes, references } = fastapiResolver.extract!('main.py', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts route from router.post', () => { + const src = ` +@router.post('/items') +def create_item(item: Item): + pass +`; + const { nodes, references } = fastapiResolver.extract!('items.py', src); + expect(nodes[0].name).toBe('POST /items'); + expect(references[0].referenceName).toBe('create_item'); + }); +}); diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts index fbb8fd54..0b01d05e 100644 --- a/src/resolution/frameworks/python.ts +++ b/src/resolution/frameworks/python.ts @@ -5,7 +5,7 @@ */ import { Node } from '../../types'; -import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types'; export const djangoResolver: FrameworkResolver = { name: 'django', @@ -112,166 +112,148 @@ function resolveHandlerName(expr: string): { name: string; kind: 'references' | export const flaskResolver: FrameworkResolver = { name: 'flask', + languages: ['python'], - detect(context: ResolutionContext): boolean { + detect(context) { const requirements = context.readFile('requirements.txt'); - if (requirements && (requirements.includes('flask') || requirements.includes('Flask'))) { - return true; - } - + if (requirements && /\bflask\b/i.test(requirements)) 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) { + 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; - } + if (content && content.includes('Flask(__name__)')) return true; } - return false; }, - resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { - // Pattern 1: Blueprint references + 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', - }; - } + 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}`, - kind: 'route', - name: `${routePath}`, - qualifiedName: `${filePath}::route:${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, content, { + // 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; + extract(filePath, content) { + if (!filePath.endsWith('.py')) return { nodes: [], references: [] }; + return extractDecoratorRoutes(filePath, content, { + // 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', + }); + }, +}; - let match; - while ((match = routePattern.exec(content)) !== null) { - const [, _appOrRouter, method, routePath] = match; - const line = content.slice(0, match.index).split('\n').length; +interface DecoratorRouteOpts { + decoratorRegex: RegExp; + defaultMethod: string; + methodGroup?: number; + methodFromGroup?: number; // methods=[...] list + pathGroup: number; + handlerGroup?: number; + findHandler?: boolean; + language: 'python'; +} - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${routePath}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${routePath}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${routePath}`, +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, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, language: 'python', - updatedAt: now, }); } - - return nodes; - }, -}; + } + return { nodes, references }; +} // Directory patterns const MODEL_DIRS = ['models', 'app/models', 'src/models']; From ce02005b1fdd8768b8250aa0d8241d299e46911e Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:31:16 -0600 Subject: [PATCH 06/18] feat(express): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 26 ++++++++++ src/resolution/frameworks/express.ts | 74 ++++++++++++++++------------ 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 8626cc7f..cb3c32b5 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -148,3 +148,29 @@ def create_item(item: Item): expect(references[0].referenceName).toBe('create_item'); }); }); + +import { expressResolver } from '../src/resolution/frameworks/express'; + +describe('expressResolver.extract', () => { + it('extracts route with inline handler reference', () => { + const src = `app.get('/users', listUsers);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts route with router.post and middleware chain', () => { + const src = `router.post('/items', auth, createItem);\n`; + const { nodes, references } = expressResolver.extract!('items.ts', src); + expect(nodes[0].name).toBe('POST /items'); + // Multiple handlers: prefer the LAST one (convention: middleware first, handler last) + expect(references[0].referenceName).toBe('createItem'); + }); + + it('extracts route with controller method reference', () => { + const src = `app.get('/x', userController.list);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(references[0].referenceName).toBe('list'); + }); +}); diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 0afa7e03..0cab8dc9 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -7,8 +7,15 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +function extractTailIdent(expr: string): string | null { + const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, ''); + const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/); + return m ? m[1]! : null; +} + export const expressResolver: FrameworkResolver = { name: 'express', + languages: ['javascript', 'typescript'], detect(context: ResolutionContext): boolean { // Check for Express in package.json @@ -90,44 +97,49 @@ export const expressResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!/\.(m?js|tsx?|cjs)$/.test(filePath)) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - - // Extract route definitions - // app.get('/path', handler) or router.get('/path', handler) - const routePatterns = [ - /(app|router)\.(get|post|put|patch|delete|all|use)\(\s*['"]([^'"]+)['"]/g, - ]; - - for (const pattern of routePatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - const [, _obj, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - // Skip middleware use() without paths - if (method === 'use' && !path?.startsWith('/')) { - continue; - } - - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + // (app|router).METHOD('/path', handler-expr) + const regex = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + const [, _obj, method, routePath, handlers] = match; + if (method === 'use' && !routePath!.startsWith('/')) continue; + const line = content.slice(0, match.index).split('\n').length; + const routeNode: Node = { + id: `route:${filePath}:${line}:${method!.toUpperCase()}:${routePath}`, + kind: 'route', + name: `${method!.toUpperCase()} ${routePath}`, + qualifiedName: `${filePath}::${method!.toUpperCase()}:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: detectLanguage(filePath), + updatedAt: now, + }; + nodes.push(routeNode); + // Handler is the LAST comma-separated argument; earlier ones are middleware. + const parts = handlers!.split(',').map((s) => s.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + const handlerName = last ? extractTailIdent(last) : null; + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, language: detectLanguage(filePath), - updatedAt: now, }); } } - - return nodes; + return { nodes, references }; }, }; From 92230af3b89d7571b1e87101f884f1ad5b2a986b Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:36:00 -0600 Subject: [PATCH 07/18] feat(laravel): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 24 +++++ src/resolution/frameworks/laravel.ts | 140 ++++++++++++++++++--------- 2 files changed, 120 insertions(+), 44 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index cb3c32b5..5fe864a5 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -174,3 +174,27 @@ describe('expressResolver.extract', () => { expect(references[0].referenceName).toBe('list'); }); }); + +import { laravelResolver } from '../src/resolution/frameworks/laravel'; + +describe('laravelResolver.extract', () => { + it('extracts route with controller tuple syntax', () => { + const src = `Route::get('/users', [UserController::class, 'index']);\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('index'); + }); + + it('extracts route with Controller@action syntax', () => { + const src = `Route::post('/users', 'UserController@store');\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(references[0].referenceName).toBe('store'); + }); + + it('extracts resource route', () => { + const src = `Route::resource('users', UserController::class);\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(nodes[0].kind).toBe('route'); + expect(references[0].referenceName).toBe('UserController'); + }); +}); diff --git a/src/resolution/frameworks/laravel.ts b/src/resolution/frameworks/laravel.ts index d6a79885..01fbe7cc 100644 --- a/src/resolution/frameworks/laravel.ts +++ b/src/resolution/frameworks/laravel.ts @@ -36,6 +36,7 @@ export const FACADE_MAPPINGS: Record = { export const laravelResolver: FrameworkResolver = { name: 'laravel', + languages: ['php'], detect(context: ResolutionContext): boolean { // Check for artisan file (Laravel signature) @@ -90,63 +91,114 @@ export const laravelResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.php')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract route definitions - const routePatterns = [ - // Route::get('/path', ...) - /Route::(get|post|put|patch|delete|options|any)\(\s*['"]([^'"]+)['"]/g, - // Route::resource('name', ...) - /Route::resource\(\s*['"]([^'"]+)['"]/g, - // Route::apiResource('name', ...) - /Route::apiResource\(\s*['"]([^'"]+)['"]/g, - ]; - - for (const pattern of routePatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - if (pattern.source.includes('resource')) { - const [, resourceName] = match; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `route:${filePath}:resource:${resourceName}:${line}`, - kind: 'route', - name: `resource:${resourceName}`, - qualifiedName: `${filePath}::resource:${resourceName}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'php', - updatedAt: now, - }); - } else { - const [, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + // Route::METHOD('/path', handler-expr) + // handler-expr can be: [Class::class, 'method'] | 'Controller@method' | Closure | Class::class + const routeRegex = /Route::(get|post|put|patch|delete|options|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, method, routePath, handlerExpr] = match; + const line = content.slice(0, match.index).split('\n').length; + const upper = method!.toUpperCase(); + const routeNode: Node = { + id: `route:${filePath}:${line}:${upper}:${routePath}`, + kind: 'route', + name: `${upper} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'php', + updatedAt: now, + }; + nodes.push(routeNode); + + const handlerName = extractLaravelHandler(handlerExpr!); + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'php', + }); + } + } + + // Route::resource('name', Controller::class) / Route::apiResource('name', Controller::class) + const resourceRegex = /Route::(resource|apiResource)\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*([^)]+))?\)/g; + while ((match = resourceRegex.exec(content)) !== null) { + const [, _fn, resourceName, handlerExpr] = match; + const line = content.slice(0, match.index).split('\n').length; + const routeNode: Node = { + id: `route:${filePath}:${line}:RESOURCE:${resourceName}`, + kind: 'route', + name: `resource:${resourceName}`, + qualifiedName: `${filePath}::route:${resourceName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'php', + updatedAt: now, + }; + nodes.push(routeNode); + + if (handlerExpr) { + const controllerName = extractLaravelHandler(handlerExpr); + if (controllerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: controllerName, + referenceKind: 'imports', + line, + column: 0, filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, language: 'php', - updatedAt: now, }); } } } - return nodes; + return { nodes, references }; }, }; +/** + * Parse a Laravel route handler expression and return the symbol to link. + * - `[Class::class, 'method']` -> `method` + * - `'Controller@method'` -> `method` + * - `Class::class` -> `Class` + * - anything else (closure etc) -> null + */ +function extractLaravelHandler(expr: string): string | null { + const trimmed = expr.trim(); + + // [Class::class, 'method'] — grab the string literal + const tupleMatch = trimmed.match(/^\[\s*[^,]+,\s*['"]([^'"]+)['"]\s*\]/); + if (tupleMatch) return tupleMatch[1]!; + + // 'Controller@method' + const atMatch = trimmed.match(/^['"]([^'"@]+)@([^'"]+)['"]$/); + if (atMatch) return atMatch[2]!; + + // Controller::class + const classMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)::class/); + if (classMatch) return classMatch[1]!; + + return null; +} + /** * Resolve a Model::method() call */ From 77baeb4a29fb786a499e82385ecc9d25f4b766ed Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:37:09 -0600 Subject: [PATCH 08/18] feat(rails): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 17 ++++ src/resolution/frameworks/ruby.ts | 130 +++++++++--------------------- 2 files changed, 54 insertions(+), 93 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 5fe864a5..1755753c 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -198,3 +198,20 @@ describe('laravelResolver.extract', () => { expect(references[0].referenceName).toBe('UserController'); }); }); + +import { railsResolver } from '../src/resolution/frameworks/ruby'; + +describe('railsResolver.extract', () => { + it('extracts route with controller#action syntax', () => { + const src = `get '/users', to: 'users#index'\n`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('index'); + }); + + it('extracts route without to: keyword', () => { + const src = `post '/items' => 'items#create'\n`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(references[0].referenceName).toBe('create'); + }); +}); diff --git a/src/resolution/frameworks/ruby.ts b/src/resolution/frameworks/ruby.ts index 49c9a2e8..b990562d 100644 --- a/src/resolution/frameworks/ruby.ts +++ b/src/resolution/frameworks/ruby.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const railsResolver: FrameworkResolver = { name: 'rails', + languages: ['ruby'], detect(context: ResolutionContext): boolean { // Check for Gemfile with rails @@ -85,104 +86,47 @@ export const railsResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.rb')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract route definitions from config/routes.rb - if (filePath.includes('routes.rb')) { - // get/post/put/patch/delete 'path' - const routePatterns = [ - /(get|post|put|patch|delete)\s+['"]([^'"]+)['"]/g, - /resources?\s+:(\w+)/g, - /root\s+['"]([^'"]+)['"]/g, - /root\s+to:\s*['"]([^'"]+)['"]/g, - ]; - - for (const pattern of routePatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - const line = content.slice(0, match.index).split('\n').length; - - if (pattern.source.includes('resources')) { - const [, resourceName] = match; - nodes.push({ - id: `route:${filePath}:resource:${resourceName}:${line}`, - kind: 'route', - name: `resource:${resourceName}`, - qualifiedName: `${filePath}::resource:${resourceName}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'ruby', - updatedAt: now, - }); - } else if (pattern.source.includes('root')) { - const [, target] = match; - nodes.push({ - id: `route:${filePath}:root:${line}`, - kind: 'route', - name: `/ -> ${target}`, - qualifiedName: `${filePath}::root`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'ruby', - updatedAt: now, - }); - } else { - const [, method, path] = match; - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'ruby', - updatedAt: now, - }); - } - } - } - } - - // Extract controller actions - if (filePath.includes('controllers/') && filePath.endsWith('.rb')) { - const actionPattern = /def\s+(\w+)/g; - let match; - while ((match = actionPattern.exec(content)) !== null) { - const [, actionName] = match; - const line = content.slice(0, match.index).split('\n').length; - - // Skip private methods and common Rails callbacks - const privateMethods = ['initialize', 'set_', 'before_', 'after_']; - if (!privateMethods.some((p) => actionName!.startsWith(p))) { - nodes.push({ - id: `action:${filePath}:${actionName}:${line}`, - kind: 'method', - name: actionName!, - qualifiedName: `${filePath}::${actionName}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'ruby', - updatedAt: now, - }); - } - } + // get/post/put/patch/delete/match '/path', to: 'controller#action' + // Also: get '/path' => 'controller#action' + const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g; + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, method, routePath, _controller, action] = match; + const line = content.slice(0, match.index).split('\n').length; + const upper = method!.toUpperCase(); + const routeNode: Node = { + id: `route:${filePath}:${line}:${upper}:${routePath}`, + kind: 'route', + name: `${upper} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'ruby', + updatedAt: now, + }; + nodes.push(routeNode); + + references.push({ + fromNodeId: routeNode.id, + referenceName: action!, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'ruby', + }); } - return nodes; + return { nodes, references }; }, }; From aba50d7216f0b2e533a721672405a5c36c598283 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:38:12 -0600 Subject: [PATCH 09/18] feat(spring): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 16 ++++++ src/resolution/frameworks/java.ts | 81 ++++++++++++++----------------- 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 1755753c..2aecba4e 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -215,3 +215,19 @@ describe('railsResolver.extract', () => { expect(references[0].referenceName).toBe('create'); }); }); + +import { springResolver } from '../src/resolution/frameworks/java'; + +describe('springResolver.extract', () => { + it('extracts route with @GetMapping and next method', () => { + const src = ` +@GetMapping("/users") +public List listUsers() { + return users; +} +`; + const { nodes, references } = springResolver.extract!('UserController.java', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); +}); diff --git a/src/resolution/frameworks/java.ts b/src/resolution/frameworks/java.ts index 2acb2b8a..3ed02b29 100644 --- a/src/resolution/frameworks/java.ts +++ b/src/resolution/frameworks/java.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const springResolver: FrameworkResolver = { name: 'spring', + languages: ['java'], detect(context: ResolutionContext): boolean { // Check for pom.xml with Spring @@ -116,63 +117,53 @@ export const springResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.java')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract REST endpoints - // @GetMapping("/path"), @PostMapping("/path"), etc. - const mappingPatterns = [ - /@(Get|Post|Put|Patch|Delete|Request)Mapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["']/g, - /@(Get|Post|Put|Patch|Delete|Request)Mapping\s*\(\s*(?:path\s*=\s*)?["']([^"']+)["']/g, - ]; - - for (const pattern of mappingPatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - const [, mappingType, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - const method = mappingType === 'Request' ? 'ANY' : mappingType!.toUpperCase(); - - nodes.push({ - id: `route:${filePath}:${method}:${path}:${line}`, - kind: 'route', - name: `${method} ${path}`, - qualifiedName: `${filePath}::${method}:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'java', - updatedAt: now, - }); - } - } - - // Extract class-level @RequestMapping for base path - const baseMappingMatch = content.match(/@RequestMapping\s*\(\s*["']([^"']+)["']\s*\)/); - if (baseMappingMatch) { - const [, basePath] = baseMappingMatch; - const line = content.slice(0, baseMappingMatch.index).split('\n').length; - - nodes.push({ - id: `route:${filePath}:BASE:${basePath}:${line}`, + // @GetMapping("/path"), @PostMapping(value = "/path"), @RequestMapping("/path") + const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)["'][^)]*\)/g; + let match: RegExpExecArray | null; + while ((match = mappingRegex.exec(content)) !== null) { + const [, mappingName, routePath] = match; + const line = content.slice(0, match.index).split('\n').length; + const method = + mappingName === 'RequestMapping' ? 'ANY' : mappingName!.replace(/Mapping$/, '').toUpperCase(); + + const routeNode: Node = { + id: `route:${filePath}:${line}:${method}:${routePath}`, kind: 'route', - name: `BASE ${basePath}`, - qualifiedName: `${filePath}::BASE:${basePath}`, + name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, startColumn: 0, - endColumn: baseMappingMatch[0].length, + endColumn: match[0].length, language: 'java', updatedAt: now, - }); + }; + nodes.push(routeNode); + + // Look for the next public/private/protected method after the annotation + const tail = content.slice(match.index + match[0].length); + const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{]*?\s+(\w+)\s*\(/); + if (methodMatch) { + references.push({ + fromNodeId: routeNode.id, + referenceName: methodMatch[1]!, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'java', + }); + } } - return nodes; + return { nodes, references }; }, }; From f561e324e26b08564463cd758e5ee17383936ba4 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:39:25 -0600 Subject: [PATCH 10/18] feat(go): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 17 +++++ src/resolution/frameworks/go.ts | 123 +++++++++++--------------------- 2 files changed, 58 insertions(+), 82 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 2aecba4e..5e48e4bf 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -231,3 +231,20 @@ public List listUsers() { expect(references[0].referenceName).toBe('listUsers'); }); }); + +import { goResolver } from '../src/resolution/frameworks/go'; + +describe('goResolver.extract', () => { + it('extracts route from r.GET', () => { + const src = `r.GET("/users", listUsers)\n`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts route from router.HandleFunc', () => { + const src = `router.HandleFunc("/items", createItem)\n`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(references[0].referenceName).toBe('createItem'); + }); +}); diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index 8fca7565..a6318482 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const goResolver: FrameworkResolver = { name: 'go', + languages: ['go'], detect(context: ResolutionContext): boolean { // Check for go.mod file (Go modules) @@ -78,47 +79,29 @@ export const goResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.go')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract Gin routes - // r.GET("/path", handler), router.POST("/path", handler), etc. - const ginRoutePattern = /\.\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(\s*["']([^"']+)["']/g; - - let match; - while ((match = ginRoutePattern.exec(content)) !== null) { - const [, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - nodes.push({ - id: `route:${filePath}:${method}:${path}:${line}`, - kind: 'route', - name: `${method} ${path}`, - qualifiedName: `${filePath}::${method}:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'go', - updatedAt: now, - }); - } - - // Extract Echo routes - // e.GET("/path", handler) - const echoRoutePattern = /e\.\s*(GET|POST|PUT|PATCH|DELETE)\s*\(\s*["']([^"']+)["']/g; - - while ((match = echoRoutePattern.exec(content)) !== null) { - const [, method, path] = match; + // (router|r|mux|app).METHOD("/path", handler) + // Handles Gin (GET/POST/...), Chi (Get/Post/...), net/http (HandleFunc/Handle). + const routeRegex = /\b(?:router|r|mux|app|e)\.(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Get|Post|Put|Patch|Delete|Handle|HandleFunc)\s*\(\s*"([^"]+)"\s*,\s*([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, rawMethod, routePath, handlerExpr] = match; const line = content.slice(0, match.index).split('\n').length; + const method = + rawMethod === 'Handle' || rawMethod === 'HandleFunc' + ? 'ANY' + : rawMethod!.toUpperCase(); - nodes.push({ - id: `route:${filePath}:${method}:${path}:${line}`, + const routeNode: Node = { + id: `route:${filePath}:${line}:${method}:${routePath}`, kind: 'route', - name: `${method} ${path}`, - qualifiedName: `${filePath}::${method}:${path}`, + name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, @@ -126,58 +109,34 @@ export const goResolver: FrameworkResolver = { endColumn: match[0].length, language: 'go', updatedAt: now, - }); - } - - // Extract Chi routes - // r.Get("/path", handler), r.Post("/path", handler) - const chiRoutePattern = /r\.\s*(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"']+)["']/g; - - while ((match = chiRoutePattern.exec(content)) !== null) { - const [, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'go', - updatedAt: now, - }); - } - - // Extract standard library http.HandleFunc - const httpHandlePattern = /http\.HandleFunc\s*\(\s*["']([^"']+)["']/g; - - while ((match = httpHandlePattern.exec(content)) !== null) { - const [, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - nodes.push({ - id: `route:${filePath}:ANY:${path}:${line}`, - kind: 'route', - name: `ANY ${path}`, - qualifiedName: `${filePath}::ANY:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'go', - updatedAt: now, - }); + }; + nodes.push(routeNode); + + const handlerName = extractGoTailIdent(handlerExpr!); + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'go', + }); + } } - return nodes; + return { nodes, references }; }, }; +/** Extract the last identifier from an expression like `pkg.Sub.handler` or `handler`. */ +function extractGoTailIdent(expr: string): string | null { + const cleaned = expr.trim().replace(/\s+/g, '').replace(/\(\)$/, ''); + const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/); + return m ? m[1]! : null; +} + // Directory patterns for framework resolution const HANDLER_DIRS = ['handler', 'handlers', 'api', 'routes', 'controller', 'controllers']; const SERVICE_DIRS = ['service', 'services', 'repository', 'store', 'pkg']; From 1d820aac06816cff544e5952b220d34be3c7d490 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:40:31 -0600 Subject: [PATCH 11/18] feat(rust): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 11 ++++ src/resolution/frameworks/rust.ts | 95 ++++++++++++++++--------------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 5e48e4bf..da81f9c0 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -248,3 +248,14 @@ describe('goResolver.extract', () => { expect(references[0].referenceName).toBe('createItem'); }); }); + +import { rustResolver } from '../src/resolution/frameworks/rust'; + +describe('rustResolver.extract', () => { + it('extracts route from axum .route with get()', () => { + const src = `let app = Router::new().route("/users", get(list_users));\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); +}); diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index 5ab10bc3..67fdb282 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const rustResolver: FrameworkResolver = { name: 'rust', + languages: ['rust'], detect(context: ResolutionContext): boolean { // Check for Cargo.toml (Rust project signature) @@ -71,24 +72,26 @@ export const rustResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.rs')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract Actix-web routes - // #[get("/path")], #[post("/path")], etc. - const actixRoutePattern = /#\[(get|post|put|patch|delete)\s*\(\s*["']([^"']+)["']/g; - - let match; - while ((match = actixRoutePattern.exec(content)) !== null) { - const [, method, path] = match; + // Actix-web / Rocket attribute: #[get("/path")] fn handler(..) + // Capture the method, path, and the fn identifier that follows. + const attrRegex = /#\[(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"']+)["'][^\]]*\)\]/g; + let match: RegExpExecArray | null; + while ((match = attrRegex.exec(content)) !== null) { + const [, method, routePath] = match; const line = content.slice(0, match.index).split('\n').length; + const upper = method!.toUpperCase(); - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, + const routeNode: Node = { + id: `route:${filePath}:${line}:${upper}:${routePath}`, kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + name: `${upper} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, @@ -96,49 +99,36 @@ export const rustResolver: FrameworkResolver = { endColumn: match[0].length, language: 'rust', updatedAt: now, - }); - } - - // Extract Rocket routes - // #[get("/path")], #[post("/path", ...)] - const rocketRoutePattern = /#\[(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"']+)["']/g; - - while ((match = rocketRoutePattern.exec(content)) !== null) { - const [, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - - // Avoid duplicates from actix pattern - const routeId = `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`; - if (!nodes.some((n) => n.id === routeId)) { - nodes.push({ - id: routeId, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + }; + nodes.push(routeNode); + + const tail = content.slice(match.index + match[0].length); + const fnMatch = tail.match(/\n\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/); + if (fnMatch) { + references.push({ + fromNodeId: routeNode.id, + referenceName: fnMatch[1]!, + referenceKind: 'references', + line, + column: 0, filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, language: 'rust', - updatedAt: now, }); } } - // Extract Axum routes (method chaining style) - // .route("/path", get(handler)) - const axumRoutePattern = /\.route\s*\(\s*["']([^"']+)["']\s*,\s*(get|post|put|patch|delete)/g; - - while ((match = axumRoutePattern.exec(content)) !== null) { - const [, path, method] = match; + // Axum: .route("/path", get(handler)) + const axumRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g; + while ((match = axumRegex.exec(content)) !== null) { + const [, routePath, method, handler] = match; const line = content.slice(0, match.index).split('\n').length; + const upper = method!.toUpperCase(); - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, + const routeNode: Node = { + id: `route:${filePath}:${line}:${upper}:${routePath}`, kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + name: `${upper} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, @@ -146,10 +136,21 @@ export const rustResolver: FrameworkResolver = { endColumn: match[0].length, language: 'rust', updatedAt: now, + }; + nodes.push(routeNode); + + references.push({ + fromNodeId: routeNode.id, + referenceName: handler!, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'rust', }); } - return nodes; + return { nodes, references }; }, }; From 94c88bd43c6d411de01e1cb0ea3f61b702319ba9 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:41:42 -0600 Subject: [PATCH 12/18] feat(aspnet): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 17 ++++ src/resolution/frameworks/csharp.ts | 150 +++++++++++++--------------- 2 files changed, 89 insertions(+), 78 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index da81f9c0..a04cdac0 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -259,3 +259,20 @@ describe('rustResolver.extract', () => { expect(references[0].referenceName).toBe('list_users'); }); }); + +import { aspnetResolver } from '../src/resolution/frameworks/csharp'; + +describe('aspnetResolver.extract', () => { + it('extracts route from [HttpGet] attribute', () => { + const src = ` +[HttpGet("/users")] +public IActionResult ListUsers() +{ + return Ok(); +} +`; + const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('ListUsers'); + }); +}); diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 1e170be4..536526ae 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const aspnetResolver: FrameworkResolver = { name: 'aspnet', + languages: ['csharp'], detect(context: ResolutionContext): boolean { // Check for .csproj files with ASP.NET references @@ -114,91 +115,63 @@ export const aspnetResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.cs')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract route attributes - // [HttpGet("path")], [HttpPost("path")], [Route("path")] - const routePatterns = [ - /\[(Http(Get|Post|Put|Patch|Delete))\s*\(\s*["']([^"']+)["']\s*\)\]/g, - /\[(Http(Get|Post|Put|Patch|Delete))\s*\]/g, - /\[Route\s*\(\s*["']([^"']+)["']\s*\)\]/g, - ]; - - for (const pattern of routePatterns) { - let match; - while ((match = pattern.exec(content)) !== null) { - const line = content.slice(0, match.index).split('\n').length; - - if (pattern.source.includes('Http')) { - if (match[3]) { - // HttpGet("path") style - const [, , method, path] = match; - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'csharp', - updatedAt: now, - }); - } else if (match[2]) { - // HttpGet style without path - const [, , method] = match; - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'csharp', - updatedAt: now, - }); - } - } else { - // [Route("path")] style - const [, path] = match; - nodes.push({ - id: `route:${filePath}:ROUTE:${path}:${line}`, - kind: 'route', - name: `ROUTE ${path}`, - qualifiedName: `${filePath}::ROUTE:${path}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'csharp', - updatedAt: now, - }); - } + // [HttpGet("path")], [HttpPost("path")], etc. + const attrRegex = /\[(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete)\s*\(\s*"([^"]+)"\s*\)\]/g; + let match: RegExpExecArray | null; + while ((match = attrRegex.exec(content)) !== null) { + const [, verb, routePath] = match; + const method = verb!.replace(/^Http/, '').toUpperCase(); + const line = content.slice(0, match.index).split('\n').length; + + const routeNode: Node = { + id: `route:${filePath}:${line}:${method}:${routePath}`, + kind: 'route', + name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'csharp', + updatedAt: now, + }; + nodes.push(routeNode); + + // Capture the next method declaration + const tail = content.slice(match.index + match[0].length); + const methodMatch = tail.match(/(?:public|private|protected|internal)\s+[\w<>,\s\[\]]+?\s+(\w+)\s*\(/); + if (methodMatch) { + references.push({ + fromNodeId: routeNode.id, + referenceName: methodMatch[1]!, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'csharp', + }); } } - // Extract minimal API routes (ASP.NET Core 6+) - // app.MapGet("/path", ...), app.MapPost("/path", ...) - const minimalApiPattern = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"']+)["']/g; - - let match; - while ((match = minimalApiPattern.exec(content)) !== null) { - const [, method, path] = match; + // Minimal APIs: app.MapGet("/path", handler) + const minimalRegex = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"\s*,\s*([^,)]+)/g; + while ((match = minimalRegex.exec(content)) !== null) { + const [, verb, routePath, handlerExpr] = match; + const method = verb!.toUpperCase(); const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, + const routeNode: Node = { + id: `route:${filePath}:${line}:${method}:${routePath}`, kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, @@ -206,13 +179,34 @@ export const aspnetResolver: FrameworkResolver = { endColumn: match[0].length, language: 'csharp', updatedAt: now, - }); + }; + nodes.push(routeNode); + + const handlerName = extractCSharpTailIdent(handlerExpr!); + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'csharp', + }); + } } - return nodes; + return { nodes, references }; }, }; +/** Extract last identifier from an expression like `MyService.Handler` or `Handler`. */ +function extractCSharpTailIdent(expr: string): string | null { + const cleaned = expr.trim().replace(/\s+/g, ''); + const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/); + return m ? m[1]! : null; +} + // Directory patterns const CONTROLLER_DIRS = ['/Controllers/']; const SERVICE_DIRS = ['/Services/', '/Service/', '/Application/']; From 1ccf94293d542da4e0775669676df7f813157ae4 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:43:24 -0600 Subject: [PATCH 13/18] feat(swift,vapor): emit route nodes and route->handler references --- __tests__/frameworks.test.ts | 11 ++++ src/resolution/frameworks/swift.ts | 86 +++++++++++++++--------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index a04cdac0..e9a12d8b 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -276,3 +276,14 @@ public IActionResult ListUsers() expect(references[0].referenceName).toBe('ListUsers'); }); }); + +import { vaporResolver } from '../src/resolution/frameworks/swift'; + +describe('vaporResolver.extract', () => { + it('extracts route from app.get with use:', () => { + const src = `app.get("users", use: listUsers)\n`; + const { nodes, references } = vaporResolver.extract!('routes.swift', src); + expect(nodes[0].name).toBe('GET users'); + expect(references[0].referenceName).toBe('listUsers'); + }); +}); diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 25f62017..e4460fd5 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const swiftUIResolver: FrameworkResolver = { name: 'swiftui', + languages: ['swift'], detect(context: ResolutionContext): boolean { // Check for SwiftUI imports in Swift files @@ -75,7 +76,8 @@ export const swiftUIResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); @@ -83,7 +85,7 @@ export const swiftUIResolver: FrameworkResolver = { // struct ContentView: View { ... } const viewPattern = /struct\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*View/g; - let match; + let match: RegExpExecArray | null; while ((match = viewPattern.exec(content)) !== null) { const [, viewName] = match; const line = content.slice(0, match.index).split('\n').length; @@ -125,12 +127,13 @@ export const swiftUIResolver: FrameworkResolver = { }); } - return nodes; + return { nodes, references: [] }; }, }; export const uikitResolver: FrameworkResolver = { name: 'uikit', + languages: ['swift'], detect(context: ResolutionContext): boolean { const allFiles = context.getAllFiles(); @@ -206,14 +209,15 @@ export const uikitResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); // Extract UIViewController subclasses const vcPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIViewController/g; - let match; + let match: RegExpExecArray | null; while ((match = vcPattern.exec(content)) !== null) { const [, vcName] = match; const line = content.slice(0, match.index).split('\n').length; @@ -255,12 +259,13 @@ export const uikitResolver: FrameworkResolver = { }); } - return nodes; + return { nodes, references: [] }; }, }; export const vaporResolver: FrameworkResolver = { name: 'vapor', + languages: ['swift'], detect(context: ResolutionContext): boolean { // Check for Package.swift with Vapor dependency @@ -326,24 +331,25 @@ export const vaporResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { + if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); - // Extract Vapor routes - // app.get("path") { ... }, app.post("path") { ... } - const routePattern = /\.(get|post|put|patch|delete)\s*\(\s*["']([^"']+)["']/g; - - let match; - while ((match = routePattern.exec(content)) !== null) { - const [, method, path] = match; + // Vapor: (app|router|routes).METHOD("path", use: handler) + const routeRegex = /\b(?:app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g; + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const [, method, routePath, handlerExpr] = match; const line = content.slice(0, match.index).split('\n').length; + const upper = method!.toUpperCase(); - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${path}:${line}`, + const routeNode: Node = { + id: `route:${filePath}:${line}:${upper}:${routePath}`, kind: 'route', - name: `${method!.toUpperCase()} ${path}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${path}`, + name: `${upper} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, @@ -351,34 +357,26 @@ export const vaporResolver: FrameworkResolver = { endColumn: match[0].length, language: 'swift', updatedAt: now, - }); - } - - // Extract grouped routes - // app.grouped("api").get("users") { ... } - const groupedRoutePattern = /\.grouped\s*\(\s*["']([^"']+)["']\s*\)\s*\.(get|post|put|patch|delete)\s*\(\s*["']([^"']+)["']/g; - - while ((match = groupedRoutePattern.exec(content)) !== null) { - const [, prefix, method, path] = match; - const line = content.slice(0, match.index).split('\n').length; - const fullPath = `${prefix}/${path}`; - - nodes.push({ - id: `route:${filePath}:${method!.toUpperCase()}:${fullPath}:${line}`, - kind: 'route', - name: `${method!.toUpperCase()} /${fullPath}`, - qualifiedName: `${filePath}::${method!.toUpperCase()}:${fullPath}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: match[0].length, - language: 'swift', - updatedAt: now, - }); + }; + nodes.push(routeNode); + + // Last segment of dotted path (e.g. UserController.list -> list) + const parts = handlerExpr!.split('.'); + const handlerName = parts[parts.length - 1]; + if (handlerName) { + references.push({ + fromNodeId: routeNode.id, + referenceName: handlerName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'swift', + }); + } } - return nodes; + return { nodes, references }; }, }; From 5aa602cd7f2657a3f92283bc1b93a005531fdc3a Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:45:05 -0600 Subject: [PATCH 14/18] chore(react,svelte): migrate resolvers to extract() interface --- __tests__/frameworks.test.ts | 22 ++++++++++++++++++++++ src/resolution/frameworks/react.ts | 5 +++-- src/resolution/frameworks/svelte.ts | 5 +++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index e9a12d8b..1f4ebf13 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -287,3 +287,25 @@ describe('vaporResolver.extract', () => { expect(references[0].referenceName).toBe('listUsers'); }); }); + +import { reactResolver } from '../src/resolution/frameworks/react'; +import { svelteResolver } from '../src/resolution/frameworks/svelte'; + +describe('reactResolver.extract (smoke)', () => { + it('returns { nodes, references } shape', () => { + const src = `}/>`; + const result = reactResolver.extract!('App.tsx', src); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('references'); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.references)).toBe(true); + }); +}); + +describe('svelteResolver.extract (smoke)', () => { + it('returns { nodes, references } shape', () => { + const result = svelteResolver.extract!('+page.svelte', ''); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('references'); + }); +}); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 74395dfe..c900d489 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const reactResolver: FrameworkResolver = { name: 'react', + languages: ['javascript', 'typescript'], detect(context: ResolutionContext): boolean { // Check for React in package.json @@ -73,7 +74,7 @@ export const reactResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, content: string): Node[] { + extract(filePath, content) { const nodes: Node[] = []; const now = Date.now(); @@ -168,7 +169,7 @@ export const reactResolver: FrameworkResolver = { } } - return nodes; + return { nodes, references: [] }; }, }; diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts index 7575ba00..8848c857 100644 --- a/src/resolution/frameworks/svelte.ts +++ b/src/resolution/frameworks/svelte.ts @@ -44,6 +44,7 @@ const SVELTEKIT_MODULE_PREFIXES = [ export const svelteResolver: FrameworkResolver = { name: 'svelte', + languages: ['svelte'], detect(context: ResolutionContext): boolean { // Check for svelte or @sveltejs/kit in package.json @@ -144,7 +145,7 @@ export const svelteResolver: FrameworkResolver = { return null; }, - extractNodes(filePath: string, _content: string): Node[] { + extract(filePath, _content) { const nodes: Node[] = []; const now = Date.now(); @@ -174,7 +175,7 @@ export const svelteResolver: FrameworkResolver = { } } - return nodes; + return { nodes, references: [] }; }, }; From f701e7e3decb68d97655b1fa52faa65017efd547 Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 09:57:41 -0600 Subject: [PATCH 15/18] feat(extraction): run framework extractors after tree-sitter parse --- __tests__/frameworks-integration.test.ts | 59 +++++++++++++++++ src/extraction/index.ts | 84 ++++++++++++++++++++++-- src/extraction/parse-worker.ts | 6 +- src/extraction/tree-sitter.ts | 64 +++++++++++++----- 4 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 __tests__/frameworks-integration.test.ts diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts new file mode 100644 index 00000000..b64e8c66 --- /dev/null +++ b/__tests__/frameworks-integration.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Django end-to-end framework extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('creates a route->view edge from urls.py to view class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-django-')); + fs.writeFileSync(path.join(tmpDir, 'manage.py'), '# marker\n'); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'django==4.2\n'); + fs.mkdirSync(path.join(tmpDir, 'users')); + fs.writeFileSync(path.join(tmpDir, 'users/__init__.py'), ''); + fs.writeFileSync( + path.join(tmpDir, 'users/views.py'), + 'class UserListView:\n def get(self, request): pass\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'users/urls.py'), + 'from django.urls import path\n' + + 'from users.views import UserListView\n' + + 'urlpatterns = [path("users/", UserListView.as_view(), name="user-list")]\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Route node exists + const routes = cg.getNodesByKind('route'); + expect(routes.length).toBeGreaterThan(0); + const route = routes.find((n) => n.name === 'users/'); + expect(route).toBeDefined(); + + // View class exists + const classNodes = cg.getNodesByKind('class'); + const view = classNodes.find((n) => n.name === 'UserListView'); + expect(view).toBeDefined(); + + // Edge route -> view exists + const edges = cg.getOutgoingEdges(route!.id); + const toView = edges.find((e) => e.target === view!.id); + expect(toView).toBeDefined(); + expect(toView!.kind).toBe('references'); + + cg.close(); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 4ad056fb..bfd70db5 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -22,6 +22,8 @@ import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLangu import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot, normalizePath } from '../utils'; import picomatch from 'picomatch'; +import { detectFrameworks } from '../resolution/frameworks'; +import type { ResolutionContext } from '../resolution/types'; /** * Number of files to read in parallel during indexing. @@ -399,6 +401,13 @@ export class ExtractionOrchestrator { private rootDir: string; private config: CodeGraphConfig; private queries: QueryBuilder; + /** + * Names of frameworks detected for this project, populated by indexAll(). + * Passed to extractFromSource so framework-specific extractors (route nodes, + * middleware, etc.) run after the tree-sitter pass. Cleared if detection + * hasn't run yet so single-file re-index paths can detect on the spot. + */ + private detectedFrameworkNames: string[] | null = null; constructor(rootDir: string, config: CodeGraphConfig, queries: QueryBuilder) { this.rootDir = rootDir; @@ -406,6 +415,57 @@ export class ExtractionOrchestrator { this.queries = queries; } + /** + * Build a filesystem-backed ResolutionContext sufficient for framework + * detection. Graph-query methods (getNodesByName etc.) return empty because + * the DB hasn't been populated yet, but detect() only uses readFile, + * fileExists, and getAllFiles, so that's fine. + */ + private buildDetectionContext(files: string[]): ResolutionContext { + const rootDir = this.rootDir; + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + getAllFiles: () => files, + getProjectRoot: () => rootDir, + fileExists: (relativePath: string) => { + const full = validatePathWithinRoot(rootDir, relativePath); + if (!full) return false; + try { + return fs.existsSync(full); + } catch { + return false; + } + }, + readFile: (relativePath: string) => { + const full = validatePathWithinRoot(rootDir, relativePath); + if (!full) return null; + try { + return fs.readFileSync(full, 'utf-8'); + } catch { + return null; + } + }, + }; + } + + /** + * Detect frameworks on demand using the current scanned files (or a fresh + * scan if none are provided). Cached on the orchestrator so repeat calls + * inside a single run don't re-scan. + */ + private ensureDetectedFrameworks(files?: string[]): string[] { + if (this.detectedFrameworkNames !== null) return this.detectedFrameworkNames; + const fileList = files ?? scanDirectory(this.rootDir, this.config); + const context = this.buildDetectionContext(fileList); + this.detectedFrameworkNames = detectFrameworks(context).map((r) => r.name); + return this.detectedFrameworkNames; + } + /** * Index all files in the project */ @@ -443,6 +503,14 @@ export class ExtractionOrchestrator { }); }); + // Detect frameworks once per indexAll run using the scanned file list. + // Names are passed to each parse call so framework-specific extractors + // (route nodes, middleware, etc.) run after the tree-sitter pass. + // Framework detection is reset each run so adding e.g. requirements.txt + // between runs is picked up without restarting the process. + this.detectedFrameworkNames = null; + const frameworkNames = this.ensureDetectedFrameworks(files); + if (signal?.aborted) { return { success: false, @@ -584,7 +652,12 @@ export class ExtractionOrchestrator { async function requestParse(filePath: string, content: string): Promise { if (!WorkerClass) { // In-process fallback - return extractFromSource(filePath, content, detectLanguage(filePath, content)); + return extractFromSource( + filePath, + content, + detectLanguage(filePath, content), + frameworkNames + ); } // Recycle the worker before the next parse if we've hit the threshold. @@ -614,7 +687,7 @@ export class ExtractionOrchestrator { }, timeoutMs); pendingParses.set(id, { resolve, reject, timer }); - worker.postMessage({ type: 'parse', id, filePath, content }); + worker.postMessage({ type: 'parse', id, filePath, content, frameworkNames }); }); } @@ -1004,8 +1077,11 @@ export class ExtractionOrchestrator { }; } - // Extract from source - const result = extractFromSource(relativePath, content, language); + // Extract from source. Use cached framework names if indexAll has run, + // otherwise detect on the spot so single-file re-index paths still emit + // route nodes / middleware / etc. + const frameworkNames = this.ensureDetectedFrameworks(); + const result = extractFromSource(relativePath, content, language, frameworkNames); // Store in database if (result.nodes.length > 0 || result.errors.length === 0) { diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts index 21b239ca..8a97b27c 100644 --- a/src/extraction/parse-worker.ts +++ b/src/extraction/parse-worker.ts @@ -13,15 +13,15 @@ import type { Language, ExtractionResult } from '../types'; const PARSER_RESET_INTERVAL = 5000; const parseCounts = new Map(); -parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[] }) => { +parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[] }) => { if (msg.type === 'load-grammars') { await loadGrammarsForLanguages(msg.languages!); parentPort!.postMessage({ type: 'grammars-loaded' }); } else if (msg.type === 'parse') { - const { id, filePath, content } = msg; + const { id, filePath, content, frameworkNames } = msg; try { const language = detectLanguage(filePath!, content); - const result: ExtractionResult = extractFromSource(filePath!, content!, language); + const result: ExtractionResult = extractFromSource(filePath!, content!, language, frameworkNames); // Periodic parser reset to reclaim WASM heap memory const count = (parseCounts.get(language) ?? 0) + 1; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7345d91f..90cd5d37 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -22,6 +22,10 @@ import { EXTRACTORS } from './languages'; import { LiquidExtractor } from './liquid-extractor'; import { SvelteExtractor } from './svelte-extractor'; import { DfmExtractor } from './dfm-extractor'; +import { + getAllFrameworkResolvers, + getApplicableFrameworks, +} from '../resolution/frameworks'; // Re-export for backward compatibility export { generateNodeId } from './tree-sitter-helpers'; @@ -2310,37 +2314,67 @@ export class TreeSitterExtractor { /** - * Extract nodes and edges from source code + * Extract nodes and edges from source code. + * + * If `frameworkNames` is provided, framework-specific extractors matching + * those names and the file's language are run after the tree-sitter pass. + * Their nodes/references/errors are merged into the returned result. */ export function extractFromSource( filePath: string, source: string, - language?: Language + language?: Language, + frameworkNames?: string[] ): ExtractionResult { const detectedLanguage = language || detectLanguage(filePath, source); const fileExtension = path.extname(filePath).toLowerCase(); + let result: ExtractionResult; + // Use custom extractor for Svelte if (detectedLanguage === 'svelte') { const extractor = new SvelteExtractor(filePath, source); - return extractor.extract(); - } - - // Use custom extractor for Liquid - if (detectedLanguage === 'liquid') { + result = extractor.extract(); + } else if (detectedLanguage === 'liquid') { + // Use custom extractor for Liquid const extractor = new LiquidExtractor(filePath, source); - return extractor.extract(); - } - - // Use custom extractor for DFM/FMX form files - if ( + result = extractor.extract(); + } else if ( detectedLanguage === 'pascal' && (fileExtension === '.dfm' || fileExtension === '.fmx') ) { + // Use custom extractor for DFM/FMX form files const extractor = new DfmExtractor(filePath, source); - return extractor.extract(); + result = extractor.extract(); + } else { + const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage); + result = extractor.extract(); + } + + // Framework-specific extraction (routes, middleware, etc.) + if (frameworkNames && frameworkNames.length > 0) { + const allResolvers = getAllFrameworkResolvers(); + const applicable = getApplicableFrameworks( + allResolvers.filter((r) => frameworkNames.includes(r.name)), + detectedLanguage + ); + for (const fw of applicable) { + if (!fw.extract) continue; + try { + const fwResult = fw.extract(filePath, source); + result.nodes.push(...fwResult.nodes); + result.unresolvedReferences.push(...fwResult.references); + } catch (err) { + result.errors.push({ + message: `Framework extractor '${fw.name}' failed: ${ + err instanceof Error ? err.message : String(err) + }`, + filePath, + severity: 'warning', + }); + } + } } - const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage); - return extractor.extract(); + return result; } From 024d1cf899e67eb6593b745b0b6b3d10b5317e5a Mon Sep 17 00:00:00 2001 From: timomeara Date: Fri, 24 Apr 2026 10:05:25 -0600 Subject: [PATCH 16/18] docs: document framework route extraction --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index fd1ffaba..73a81916 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,32 @@ All tests used Claude Opus 4.6 (1M context) with Claude Code v2.1.91. Each test | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | | **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Svelte, Liquid, Pascal/Delphi | +| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 13 frameworks | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | --- +## Framework-aware Routes + +CodeGraph detects web-framework routing files and emits `route` nodes linked by `references` edges to their handler classes or functions. Querying callers of a view/controller now surfaces the URL pattern that binds it. + +| Framework | Shapes recognized | +|---|---| +| **Django** | `path()`, `re_path()`, `url()`, `include()` in `urls.py` (CBV `.as_view()`, dotted paths) | +| **Flask** | `@app.route('/path', methods=[...])`, blueprint routes | +| **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods | +| **Express** | `app.get(...)`, `router.post(...)` with middleware chains | +| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | +| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | +| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods | +| **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` | +| **Axum / actix / Rocket** | `.route("/x", get(handler))` | +| **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | +| **Vapor** | `app.get("x", use: handler)` | +| **React Router** / **SvelteKit** | Route component nodes | + +--- + ## Quick Start ### 1. Run the Installer From ce9538de83a236587c2251948a6aea562b8ebcf6 Mon Sep 17 00:00:00 2001 From: timomeara Date: Mon, 27 Apr 2026 21:18:16 -0700 Subject: [PATCH 17/18] 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. --- __tests__/strip-comments.test.ts | 126 +++++++++ src/resolution/strip-comments.ts | 469 +++++++++++++++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 __tests__/strip-comments.test.ts create mode 100644 src/resolution/strip-comments.ts diff --git a/__tests__/strip-comments.test.ts b/__tests__/strip-comments.test.ts new file mode 100644 index 00000000..3e0954f2 --- /dev/null +++ b/__tests__/strip-comments.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { stripCommentsForRegex } from '../src/resolution/strip-comments'; + +describe('stripCommentsForRegex', () => { + it('python: strips line comments', () => { + const src = "x = 1 # path('/fake/', View)\nreal = 2"; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/path\('\/fake\//); + expect(out).toMatch(/real = 2/); + }); + + it('python: strips triple-quoted docstrings', () => { + const src = `""" +path('/in-docstring/', View) +""" +real = 1 +`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/in-docstring/); + expect(out).toMatch(/real = 1/); + }); + + it('python: keeps # inside strings', () => { + const src = `path('#/fragment/', View)\n`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).toContain("'#/fragment/'"); + }); + + it('python: handles triple-single-quoted docstrings', () => { + const src = `'''\npath('/fake/')\n'''\nreal = 1\n`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/real = 1/); + }); + + it('typescript: strips //, /* */', () => { + const src = + "// app.get('/fake', x)\n/* app.get('/also-fake', y) */\napp.get('/real', z)"; + const out = stripCommentsForRegex(src, 'typescript'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('typescript: keeps // inside strings', () => { + const src = `const url = "https://example.com/path";\n`; + const out = stripCommentsForRegex(src, 'typescript'); + expect(out).toContain('https://example.com/path'); + }); + + it('php: strips //, #, and /* */', () => { + const src = + "// Route::get('/a', X::class)\n# Route::get('/b', Y::class)\n/* Route::get('/c', Z::class) */\nReal::go();"; + const out = stripCommentsForRegex(src, 'php'); + expect(out).not.toMatch(/'\/a'/); + expect(out).not.toMatch(/'\/b'/); + expect(out).not.toMatch(/'\/c'/); + expect(out).toContain('Real::go();'); + }); + + it('ruby: strips =begin/=end', () => { + const src = + "=begin\nget '/fake', to: 'x#y'\n=end\nget '/real', to: 'a#b'\n"; + const out = stripCommentsForRegex(src, 'ruby'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('ruby: strips # comments', () => { + const src = "# get '/fake', to: 'x#y'\nget '/real', to: 'a#b'\n"; + const out = stripCommentsForRegex(src, 'ruby'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('rust: handles nested block comments', () => { + const src = + '/* outer /* inner */ still in outer */ .route("/real", get(h))'; + const out = stripCommentsForRegex(src, 'rust'); + expect(out).not.toMatch(/inner/); + expect(out).toMatch(/\/real/); + }); + + it('go: keeps backtick raw strings intact, strips // comments', () => { + const src = '// r.GET("/fake", h)\nr.GET(`/real`, h2)\n'; + const out = stripCommentsForRegex(src, 'go'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/`\/real`/); + }); + + it('java: strips // and /* */ comments', () => { + const src = + '// @GetMapping("/fake")\n/* @PostMapping("/also-fake") */\n@GetMapping("/real")\n'; + const out = stripCommentsForRegex(src, 'java'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + + it('csharp: strips // and /* */ comments', () => { + const src = + '// [HttpGet("/fake")]\n/* [HttpPost("/also-fake")] */\n[HttpGet("/real")]\n'; + const out = stripCommentsForRegex(src, 'csharp'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + + it('swift: strips // and /* */ comments', () => { + const src = + '// app.get("fake", use: x)\n/* app.get("also-fake", use: y) */\napp.get("real", use: z)\n'; + const out = stripCommentsForRegex(src, 'swift'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"real"/); + }); + + it('preserves line numbers (newlines retained)', () => { + const src = "line1\n# comment with path('/fake/')\nline3"; + const out = stripCommentsForRegex(src, 'python'); + expect(out.split('\n').length).toBe(3); + expect(out.split('\n')[2]).toBe('line3'); + }); + + it('preserves overall length so source offsets stay valid', () => { + const src = "x = 1 # path('/fake/', View)\nreal = 2"; + const out = stripCommentsForRegex(src, 'python'); + expect(out.length).toBe(src.length); + }); +}); diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts new file mode 100644 index 00000000..cead1486 --- /dev/null +++ b/src/resolution/strip-comments.ts @@ -0,0 +1,469 @@ +/** + * Per-language comment stripper for framework route extractors. + * + * Replaces comment characters and string-literal contents that hide + * routing-shaped text with spaces (NOT removal) so that source offsets + * are preserved. This means `match.index` from a regex run on the + * stripped output still maps to the same line in the original source. + * + * Example: + * Input: "x = 1 # path('/fake/', V)\n real = 2" + * Output: "x = 1 \n real = 2" + * + * Why strip strings/docstrings as well as comments? Python module/class + * docstrings are a common source of false positives — they often contain + * `path('/example/', View)` examples in usage docs. We treat triple-quoted + * strings the same as comments. Single-line strings stay intact (a `#` + * inside a Python string is NOT a comment). + * + * Scope: this is a pragmatic, regex-supporting helper, not a full parser. + * It does NOT try to detect JS regex literals, Python f-string expressions, + * or shell-style heredocs. Those edge cases are not load-bearing for the + * `path(...)`, `Route::get(...)`, `app.get(...)` style patterns that + * framework extractors scan for. + */ + +export type CommentLang = + | 'python' + | 'javascript' + | 'typescript' + | 'php' + | 'ruby' + | 'java' + | 'csharp' + | 'swift' + | 'go' + | 'rust'; + +export function stripCommentsForRegex(content: string, lang: CommentLang): string { + switch (lang) { + case 'python': + return stripPython(content); + case 'ruby': + return stripRuby(content); + case 'rust': + return stripRust(content); + case 'php': + return stripPhp(content); + case 'go': + return stripGo(content); + case 'javascript': + case 'typescript': + case 'java': + case 'csharp': + case 'swift': + return stripCStyle(content, /* allowSingleQuoteStrings */ lang === 'javascript' || lang === 'typescript'); + default: + return content; + } +} + +/** + * Replace every char in a slice with spaces, but keep newlines so line + * numbers computed downstream remain valid. + */ +function blankRange(buf: string[], start: number, end: number, src: string): void { + for (let i = start; i < end; i++) { + buf[i] = src[i] === '\n' ? '\n' : ' '; + } +} + +// ---------- Python ---------- + +function stripPython(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + const c3 = src[i + 2] ?? ''; + + // Triple-quoted string: """...""" or '''...''' + if ((c === '"' || c === "'") && c2 === c && c3 === c) { + const quote = c; + const start = i; + i += 3; + while (i < n) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === quote && src[i + 1] === quote && src[i + 2] === quote) { + i += 3; + break; + } + i++; + } + blankRange(out, start, i, src); + continue; + } + + // Single-line string: '...' or "..." + if (c === '"' || c === "'") { + const quote = c; + i++; + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; // unterminated + i++; + } + if (i < n && src[i] === quote) i++; + continue; + } + + // Line comment + if (c === '#') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + i++; + } + + return out.join(''); +} + +// ---------- Ruby ---------- + +function stripRuby(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + let atLineStart = true; + + while (i < n) { + const c = src[i]!; + + // =begin / =end block comments must be at start of line (after optional whitespace) + if (atLineStart && c === '=' && src.startsWith('=begin', i)) { + const start = i; + // consume to matching =end at line start + i += '=begin'.length; + while (i < n) { + if (src[i] === '\n') { + // check next line for =end + let j = i + 1; + while (j < n && (src[j] === ' ' || src[j] === '\t')) j++; + if (src.startsWith('=end', j)) { + i = j + '=end'.length; + // consume rest of that line + while (i < n && src[i] !== '\n') i++; + break; + } + } + i++; + } + blankRange(out, start, i, src); + atLineStart = i > 0 && src[i - 1] === '\n'; + continue; + } + + // String literals + if (c === '"' || c === "'") { + const quote = c; + i++; + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === quote) i++; + atLineStart = false; + continue; + } + + // Line comment + if (c === '#') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + atLineStart = false; + continue; + } + + if (c === '\n') { + atLineStart = true; + i++; + continue; + } + if (c === ' ' || c === '\t') { + // whitespace doesn't change atLineStart + i++; + continue; + } + atLineStart = false; + i++; + } + + return out.join(''); +} + +// ---------- C-style (JS/TS/Java/C#/Swift) ---------- + +function stripCStyle(src: string, allowSingleQuoteStrings: boolean): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + + // Block comment + if (c === '/' && c2 === '*') { + const start = i; + i += 2; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; + if (i < n) i += 2; + blankRange(out, start, i, src); + continue; + } + + // Line comment + if (c === '/' && c2 === '/') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + // String literals + if (c === '"' || (allowSingleQuoteStrings && c === "'") || c === '`') { + const quote = c; + i++; + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + // Template literal can span lines; regular strings break on newline (treat as unterminated) + if (quote !== '`' && src[i] === '\n') break; + i++; + } + if (i < n && src[i] === quote) i++; + continue; + } + + i++; + } + + return out.join(''); +} + +// ---------- PHP ---------- + +function stripPhp(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + + // Block comment + if (c === '/' && c2 === '*') { + const start = i; + i += 2; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; + if (i < n) i += 2; + blankRange(out, start, i, src); + continue; + } + + // // line comment + if (c === '/' && c2 === '/') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + // # line comment (PHP supports both) + if (c === '#') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + // String literals: ', ", ` (PHP doesn't really use backticks for strings, + // but it does have shell-exec backticks; treating as a string is fine here) + if (c === '"' || c === "'" || c === '`') { + const quote = c; + i++; + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === quote) i++; + continue; + } + + i++; + } + + return out.join(''); +} + +// ---------- Go ---------- + +function stripGo(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + + // Block comment + if (c === '/' && c2 === '*') { + const start = i; + i += 2; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; + if (i < n) i += 2; + blankRange(out, start, i, src); + continue; + } + + // Line comment + if (c === '/' && c2 === '/') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + // Raw string with backticks (no escapes, can span lines) + if (c === '`') { + i++; + while (i < n && src[i] !== '`') i++; + if (i < n) i++; + continue; + } + + // Interpreted string with double quotes + if (c === '"') { + i++; + while (i < n && src[i] !== '"') { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === '"') i++; + continue; + } + + // Rune literal with single quotes (handle as a tiny string) + if (c === "'") { + i++; + while (i < n && src[i] !== "'") { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === "'") i++; + continue; + } + + i++; + } + + return out.join(''); +} + +// ---------- Rust ---------- + +function stripRust(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + + while (i < n) { + const c = src[i]!; + const c2 = src[i + 1] ?? ''; + + // Nested block comment /* ... /* ... */ ... */ + if (c === '/' && c2 === '*') { + const start = i; + i += 2; + let depth = 1; + while (i < n && depth > 0) { + if (src[i] === '/' && src[i + 1] === '*') { + depth++; + i += 2; + } else if (src[i] === '*' && src[i + 1] === '/') { + depth--; + i += 2; + } else { + i++; + } + } + blankRange(out, start, i, src); + continue; + } + + // Line comment + if (c === '/' && c2 === '/') { + const start = i; + while (i < n && src[i] !== '\n') i++; + blankRange(out, start, i, src); + continue; + } + + // String literals + if (c === '"') { + i++; + while (i < n && src[i] !== '"') { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + i++; + } + if (i < n && src[i] === '"') i++; + continue; + } + + // Char literal — keep simple: skip 'x' or '\x' + if (c === "'") { + // Could be a lifetime, e.g. 'a, but those don't contain routing text + i++; + while (i < n && src[i] !== "'") { + if (src[i] === '\\' && i + 1 < n) { + i += 2; + continue; + } + if (src[i] === '\n') break; + i++; + } + if (i < n && src[i] === "'") i++; + continue; + } + + i++; + } + + return out.join(''); +} From 469ac63d0807b9f596d7918610e5a16d55fa1652 Mon Sep 17 00:00:00 2001 From: timomeara Date: Mon, 27 Apr 2026 21:23:05 -0700 Subject: [PATCH 18/18] 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. --- __tests__/frameworks.test.ts | 153 +++++++++++++++++++++++++++ __tests__/strip-comments.test.ts | 8 ++ src/resolution/frameworks/csharp.ts | 12 ++- src/resolution/frameworks/express.ts | 7 +- src/resolution/frameworks/go.ts | 6 +- src/resolution/frameworks/java.ts | 8 +- src/resolution/frameworks/laravel.ts | 10 +- src/resolution/frameworks/python.ts | 10 +- src/resolution/frameworks/ruby.ts | 6 +- src/resolution/frameworks/rust.ts | 12 ++- src/resolution/frameworks/swift.ts | 24 +++-- 11 files changed, 219 insertions(+), 37 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 1f4ebf13..9922b70b 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -309,3 +309,156 @@ describe('svelteResolver.extract (smoke)', () => { expect(result).toHaveProperty('references'); }); }); + +// Regression tests: commented-out and docstring route examples must NOT +// surface as phantom route nodes. These would have failed before the +// strip-comments wiring (the regex would happily scan comments/docstrings). +describe('framework extractors ignore commented-out routes', () => { + it('django: skips line-comment and docstring routes', () => { + const src = ` +# urls.py example: +# path('/admin/', AdminPanel.as_view()) +""" +Other routing example: + path('/users/', UserListView.as_view()) +""" +urlpatterns = [path('/real/', RealView.as_view())] +`; + const result = djangoResolver.extract!('app/urls.py', src); + const urls = result.nodes.map((n) => n.name); + expect(urls).toEqual(['/real/']); + }); + + it('flask: skips commented-out @app.route', () => { + const src = ` +# @app.route('/fake') +# def fake_view(): +# return '' + +@app.route('/real') +def real_view(): + return '' +`; + const { nodes, references } = flaskResolver.extract!('app.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['real_view']); + }); + + it('fastapi: skips docstring example routes', () => { + const src = ` +""" +Example: + @app.get('/in-docstring') + async def doc(): + pass +""" +@app.get('/real') +async def real_handler(): + return {} +`; + const { nodes, references } = fastapiResolver.extract!('main.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['real_handler']); + }); + + it('express: skips // and /* */ commented routes', () => { + const src = ` +// app.get('/fake', fakeHandler); +/* router.post('/also-fake', otherHandler); */ +app.get('/real', realHandler); +`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['realHandler']); + }); + + it('laravel: skips // # and /* */ commented Route::* calls', () => { + const src = ` n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['index']); + }); + + it('rails: skips =begin/=end and # commented routes', () => { + const src = ` +# get '/fake', to: 'fake#index' +=begin +get '/also-fake', to: 'fake#show' +=end +get '/real', to: 'real#index' +`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['index']); + }); + + it('spring: skips // and /* */ commented @GetMapping', () => { + const src = ` +// @GetMapping("/fake") +// public List fake() { return null; } + +/* @PostMapping("/also-fake") + public void alsoFake() {} */ + +@GetMapping("/real") +public List listUsers() { return users; } +`; + const { nodes, references } = springResolver.extract!('UserController.java', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); + + it('go: skips // and /* */ commented router.METHOD calls', () => { + const src = ` +// r.GET("/fake", fakeHandler) +/* r.POST("/also-fake", anotherHandler) */ +r.GET("/real", listUsers) +`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); + + it('rust: skips // and nested /* */ commented .route() calls', () => { + const src = ` +// .route("/fake", get(fake_handler)) +/* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */ +let app = Router::new().route("/real", get(list_users)); +`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['list_users']); + }); + + it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => { + const src = ` +// [HttpGet("/fake")] +// public IActionResult Fake() { return Ok(); } + +/* [HttpPost("/also-fake")] + public IActionResult AlsoFake() { return Ok(); } */ + +[HttpGet("/real")] +public IActionResult ListUsers() { return Ok(); } +`; + const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']); + }); + + it('vapor: skips // and /* */ commented app.METHOD calls', () => { + const src = ` +// app.get("fake", use: fakeHandler) +/* app.post("also-fake", use: anotherHandler) */ +app.get("real", use: listUsers) +`; + const { nodes, references } = vaporResolver.extract!('routes.swift', src); + expect(nodes.map((n) => n.name)).toEqual(['GET real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); +}); diff --git a/__tests__/strip-comments.test.ts b/__tests__/strip-comments.test.ts index 3e0954f2..ef2ec057 100644 --- a/__tests__/strip-comments.test.ts +++ b/__tests__/strip-comments.test.ts @@ -84,9 +84,17 @@ real = 1 const src = '// r.GET("/fake", h)\nr.GET(`/real`, h2)\n'; const out = stripCommentsForRegex(src, 'go'); expect(out).not.toMatch(/fake/); + // backtick raw string contents preserved expect(out).toMatch(/`\/real`/); }); + it('go: strips block comments containing route-shaped text', () => { + const src = '/* r.GET("/fake", h) */\nr.GET("/real", h2)\n'; + const out = stripCommentsForRegex(src, 'go'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + it('java: strips // and /* */ comments', () => { const src = '// @GetMapping("/fake")\n/* @PostMapping("/also-fake") */\n@GetMapping("/real")\n'; diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 536526ae..73c38f35 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const aspnetResolver: FrameworkResolver = { name: 'aspnet', @@ -120,14 +121,15 @@ export const aspnetResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'csharp'); // [HttpGet("path")], [HttpPost("path")], etc. const attrRegex = /\[(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete)\s*\(\s*"([^"]+)"\s*\)\]/g; let match: RegExpExecArray | null; - while ((match = attrRegex.exec(content)) !== null) { + while ((match = attrRegex.exec(safe)) !== null) { const [, verb, routePath] = match; const method = verb!.replace(/^Http/, '').toUpperCase(); - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:${method}:${routePath}`, @@ -145,7 +147,7 @@ export const aspnetResolver: FrameworkResolver = { nodes.push(routeNode); // Capture the next method declaration - const tail = content.slice(match.index + match[0].length); + const tail = safe.slice(match.index + match[0].length); const methodMatch = tail.match(/(?:public|private|protected|internal)\s+[\w<>,\s\[\]]+?\s+(\w+)\s*\(/); if (methodMatch) { references.push({ @@ -162,10 +164,10 @@ export const aspnetResolver: FrameworkResolver = { // Minimal APIs: app.MapGet("/path", handler) const minimalRegex = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"\s*,\s*([^,)]+)/g; - while ((match = minimalRegex.exec(content)) !== null) { + while ((match = minimalRegex.exec(safe)) !== null) { const [, verb, routePath, handlerExpr] = match; const method = verb!.toUpperCase(); - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:${method}:${routePath}`, diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 0cab8dc9..8db72846 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; function extractTailIdent(expr: string): string | null { const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, ''); @@ -102,13 +103,15 @@ export const expressResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const lang = detectLanguage(filePath); + const safe = stripCommentsForRegex(content, lang); // (app|router).METHOD('/path', handler-expr) const regex = /\b(app|router)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; let match: RegExpExecArray | null; - while ((match = regex.exec(content)) !== null) { + while ((match = regex.exec(safe)) !== null) { const [, _obj, method, routePath, handlers] = match; if (method === 'use' && !routePath!.startsWith('/')) continue; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:${method!.toUpperCase()}:${routePath}`, kind: 'route', diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index a6318482..04f69737 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const goResolver: FrameworkResolver = { name: 'go', @@ -84,14 +85,15 @@ export const goResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'go'); // (router|r|mux|app).METHOD("/path", handler) // Handles Gin (GET/POST/...), Chi (Get/Post/...), net/http (HandleFunc/Handle). const routeRegex = /\b(?:router|r|mux|app|e)\.(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Get|Post|Put|Patch|Delete|Handle|HandleFunc)\s*\(\s*"([^"]+)"\s*,\s*([^)]+)\)/g; let match: RegExpExecArray | null; - while ((match = routeRegex.exec(content)) !== null) { + while ((match = routeRegex.exec(safe)) !== null) { const [, rawMethod, routePath, handlerExpr] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const method = rawMethod === 'Handle' || rawMethod === 'HandleFunc' ? 'ANY' diff --git a/src/resolution/frameworks/java.ts b/src/resolution/frameworks/java.ts index 3ed02b29..871816b8 100644 --- a/src/resolution/frameworks/java.ts +++ b/src/resolution/frameworks/java.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const springResolver: FrameworkResolver = { name: 'spring', @@ -122,13 +123,14 @@ export const springResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'java'); // @GetMapping("/path"), @PostMapping(value = "/path"), @RequestMapping("/path") const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)["'][^)]*\)/g; let match: RegExpExecArray | null; - while ((match = mappingRegex.exec(content)) !== null) { + while ((match = mappingRegex.exec(safe)) !== null) { const [, mappingName, routePath] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const method = mappingName === 'RequestMapping' ? 'ANY' : mappingName!.replace(/Mapping$/, '').toUpperCase(); @@ -148,7 +150,7 @@ export const springResolver: FrameworkResolver = { nodes.push(routeNode); // Look for the next public/private/protected method after the annotation - const tail = content.slice(match.index + match[0].length); + const tail = safe.slice(match.index + match[0].length); const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{]*?\s+(\w+)\s*\(/); if (methodMatch) { references.push({ diff --git a/src/resolution/frameworks/laravel.ts b/src/resolution/frameworks/laravel.ts index 01fbe7cc..e3940b07 100644 --- a/src/resolution/frameworks/laravel.ts +++ b/src/resolution/frameworks/laravel.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; /** * Laravel facade mappings to underlying classes @@ -96,14 +97,15 @@ export const laravelResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'php'); // Route::METHOD('/path', handler-expr) // handler-expr can be: [Class::class, 'method'] | 'Controller@method' | Closure | Class::class const routeRegex = /Route::(get|post|put|patch|delete|options|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; let match: RegExpExecArray | null; - while ((match = routeRegex.exec(content)) !== null) { + while ((match = routeRegex.exec(safe)) !== null) { const [, method, routePath, handlerExpr] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routeNode: Node = { id: `route:${filePath}:${line}:${upper}:${routePath}`, @@ -136,9 +138,9 @@ export const laravelResolver: FrameworkResolver = { // Route::resource('name', Controller::class) / Route::apiResource('name', Controller::class) const resourceRegex = /Route::(resource|apiResource)\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*([^)]+))?\)/g; - while ((match = resourceRegex.exec(content)) !== null) { + while ((match = resourceRegex.exec(safe)) !== null) { const [, _fn, resourceName, handlerExpr] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:RESOURCE:${resourceName}`, kind: 'route', diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts index 0b01d05e..c0a935be 100644 --- a/src/resolution/frameworks/python.ts +++ b/src/resolution/frameworks/python.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const djangoResolver: FrameworkResolver = { name: 'django', @@ -43,6 +44,7 @@ export const djangoResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'python'); // path('url', handler, name=...) / re_path(r'...', handler) / url(r'...', handler) // Capture groups: 1=function name, 2=url string, 3=handler expr @@ -50,9 +52,9 @@ export const djangoResolver: FrameworkResolver = { const routeRegex = /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([\w.]+(?:\s*\([^)]*\))?)/g; let match: RegExpExecArray | null; - while ((match = routeRegex.exec(content)) !== null) { + while ((match = routeRegex.exec(safe)) !== null) { const [, _fn, urlPath, handlerExpr] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const routeNode: Node = { id: `route:${filePath}:${line}:${urlPath}`, @@ -136,7 +138,7 @@ export const flaskResolver: FrameworkResolver = { extract(filePath, content) { if (!filePath.endsWith('.py')) return { nodes: [], references: [] }; - return extractDecoratorRoutes(filePath, content, { + 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', @@ -178,7 +180,7 @@ export const fastapiResolver: FrameworkResolver = { extract(filePath, content) { if (!filePath.endsWith('.py')) return { nodes: [], references: [] }; - return extractDecoratorRoutes(filePath, content, { + 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: '', diff --git a/src/resolution/frameworks/ruby.ts b/src/resolution/frameworks/ruby.ts index b990562d..52c6ead2 100644 --- a/src/resolution/frameworks/ruby.ts +++ b/src/resolution/frameworks/ruby.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const railsResolver: FrameworkResolver = { name: 'rails', @@ -91,14 +92,15 @@ export const railsResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'ruby'); // get/post/put/patch/delete/match '/path', to: 'controller#action' // Also: get '/path' => 'controller#action' const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g; let match: RegExpExecArray | null; - while ((match = routeRegex.exec(content)) !== null) { + while ((match = routeRegex.exec(safe)) !== null) { const [, method, routePath, _controller, action] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routeNode: Node = { id: `route:${filePath}:${line}:${upper}:${routePath}`, diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index 67fdb282..f0ce2725 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const rustResolver: FrameworkResolver = { name: 'rust', @@ -77,14 +78,15 @@ export const rustResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'rust'); // Actix-web / Rocket attribute: #[get("/path")] fn handler(..) // Capture the method, path, and the fn identifier that follows. const attrRegex = /#\[(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"']+)["'][^\]]*\)\]/g; let match: RegExpExecArray | null; - while ((match = attrRegex.exec(content)) !== null) { + while ((match = attrRegex.exec(safe)) !== null) { const [, method, routePath] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routeNode: Node = { @@ -102,7 +104,7 @@ export const rustResolver: FrameworkResolver = { }; nodes.push(routeNode); - const tail = content.slice(match.index + match[0].length); + const tail = safe.slice(match.index + match[0].length); const fnMatch = tail.match(/\n\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/); if (fnMatch) { references.push({ @@ -119,9 +121,9 @@ export const rustResolver: FrameworkResolver = { // Axum: .route("/path", get(handler)) const axumRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g; - while ((match = axumRegex.exec(content)) !== null) { + while ((match = axumRegex.exec(safe)) !== null) { const [, routePath, method, handler] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routeNode: Node = { diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index e4460fd5..461fe94d 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -6,6 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; export const swiftUIResolver: FrameworkResolver = { name: 'swiftui', @@ -80,15 +81,16 @@ export const swiftUIResolver: FrameworkResolver = { if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'swift'); // Extract SwiftUI View structs // struct ContentView: View { ... } const viewPattern = /struct\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*View/g; let match: RegExpExecArray | null; - while ((match = viewPattern.exec(content)) !== null) { + while ((match = viewPattern.exec(safe)) !== null) { const [, viewName] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `view:${filePath}:${viewName}:${line}`, @@ -108,9 +110,9 @@ export const swiftUIResolver: FrameworkResolver = { // Extract @main App entry point const appPattern = /@main\s+struct\s+(\w+)\s*:\s*App/g; - while ((match = appPattern.exec(content)) !== null) { + while ((match = appPattern.exec(safe)) !== null) { const [, appName] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `app:${filePath}:${appName}:${line}`, @@ -213,14 +215,15 @@ export const uikitResolver: FrameworkResolver = { if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'swift'); // Extract UIViewController subclasses const vcPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIViewController/g; let match: RegExpExecArray | null; - while ((match = vcPattern.exec(content)) !== null) { + while ((match = vcPattern.exec(safe)) !== null) { const [, vcName] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `viewcontroller:${filePath}:${vcName}:${line}`, @@ -240,9 +243,9 @@ export const uikitResolver: FrameworkResolver = { // Extract UIView subclasses const viewPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIView[^C]/g; - while ((match = viewPattern.exec(content)) !== null) { + while ((match = viewPattern.exec(safe)) !== null) { const [, viewName] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `uiview:${filePath}:${viewName}:${line}`, @@ -336,13 +339,14 @@ export const vaporResolver: FrameworkResolver = { const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); + const safe = stripCommentsForRegex(content, 'swift'); // Vapor: (app|router|routes).METHOD("path", use: handler) const routeRegex = /\b(?:app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g; let match: RegExpExecArray | null; - while ((match = routeRegex.exec(content)) !== null) { + while ((match = routeRegex.exec(safe)) !== null) { const [, method, routePath, handlerExpr] = match; - const line = content.slice(0, match.index).split('\n').length; + const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routeNode: Node = {