From df9e995e4a6108bcbf4f79ca7b64a16f39b6b398 Mon Sep 17 00:00:00 2001 From: marxo126 Date: Wed, 25 Mar 2026 23:07:58 +0100 Subject: [PATCH] feat(routes): link Next.js project-level middleware.ts to routes (#504) --- gitnexus/src/core/ingestion/pipeline.ts | 43 +++++- .../ingestion/route-extractors/middleware.ts | 145 ++++++++++++++++-- .../nextjs-route-mapping/middleware.ts | 13 ++ .../resolvers/route-mapping.test.ts | 31 +++- .../test/unit/route-tool-detection.test.ts | 133 +++++++++++++++- 5 files changed, 351 insertions(+), 14 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index f206e2620e..abff1fd88f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -13,7 +13,7 @@ import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/next import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; import { extractResponseShapes, extractPHPResponseShapes } from './route-extractors/response-shapes.js'; -import { extractMiddlewareChain } from './route-extractors/middleware.js'; +import { extractMiddlewareChain, extractNextjsMiddlewareConfig, compileMatcher, compiledMatcherMatchesRoute } from './route-extractors/middleware.js'; import { generateId } from '../../lib/utils.js'; import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; @@ -1184,6 +1184,47 @@ export const runPipelineFromRepo = async ( } } + + // ── Phase 3.5b: Link Next.js project-level middleware.ts to routes ── + if (routeRegistry.size > 0) { + const middlewareCandidates = allPaths.filter(p => + p === 'middleware.ts' || p === 'middleware.js' || p === 'middleware.tsx' || p === 'middleware.jsx' || + p === 'src/middleware.ts' || p === 'src/middleware.js' || p === 'src/middleware.tsx' || p === 'src/middleware.jsx' + ); + if (middlewareCandidates.length > 0) { + const mwContents = await readFileContents(repoPath, middlewareCandidates); + for (const [mwPath, mwContent] of mwContents) { + const config = extractNextjsMiddlewareConfig(mwContent); + if (!config) continue; + const mwLabel = config.wrappedFunctions.length > 0 + ? config.wrappedFunctions + : [config.exportedName]; + + // Pre-compile matchers once per middleware file + const compiled = config.matchers.map(compileMatcher).filter((m): m is NonNullable => m !== null); + + let linkedCount = 0; + for (const [routeURL] of routeRegistry) { + const matches = compiled.length === 0 || + compiled.some(cm => compiledMatcherMatchesRoute(cm, routeURL)); + if (!matches) continue; + + const routeNodeId = generateId('Route', routeURL); + const existing = graph.getNode(routeNodeId); + if (!existing) continue; + + const currentMw = (existing.properties.middleware as string[] | undefined) ?? []; + // Prepend project-level middleware (runs before handler-level wrappers) + existing.properties.middleware = [...mwLabel, ...currentMw.filter(m => !mwLabel.includes(m))]; + linkedCount++; + } + if (isDev && linkedCount > 0) { + console.log(`🛡️ Linked ${mwPath} middleware [${mwLabel.join(', ')}] to ${linkedCount} routes`); + } + } + } + } + // Scan HTML/PHP/template files for
and AJAX url patterns // Scan HTML/template files for and AJAX url patterns // Skip .php — already parsed by tree-sitter with http_client/fetch queries diff --git a/gitnexus/src/core/ingestion/route-extractors/middleware.ts b/gitnexus/src/core/ingestion/route-extractors/middleware.ts index e5e808835b..82cfc67b9f 100644 --- a/gitnexus/src/core/ingestion/route-extractors/middleware.ts +++ b/gitnexus/src/core/ingestion/route-extractors/middleware.ts @@ -3,6 +3,10 @@ * Detects wrapper patterns like: export const POST = withA(withB(withC(handler))) */ +/** Keywords that terminate middleware chain walking (not wrapper function names) */ +/** Names that are composition wrappers, not middleware functions themselves. */ +const COMPOSER_NAMES = new Set(['middleware', 'default', 'chain', 'compose']); + /** Keywords that terminate middleware chain walking (not wrapper function names) */ export const MIDDLEWARE_STOP_KEYWORDS = new Set([ 'async', 'await', 'function', 'new', 'return', 'if', 'for', 'while', 'switch', @@ -10,6 +14,22 @@ export const MIDDLEWARE_STOP_KEYWORDS = new Set([ 'event', 'ctx', 'context', 'next', ]); + +/** Walk nested wrapper calls starting at `pos` in `content`, returning function names. */ +function walkNestedWrappers(content: string, pos: number): string[] { + const names: string[] = []; + const nestedRe = /^\s*(\w+)\s*\(/; + let remaining = content.slice(pos); + let nested; + while ((nested = nestedRe.exec(remaining)) !== null) { + if (MIDDLEWARE_STOP_KEYWORDS.has(nested[1])) break; + names.push(nested[1]); + pos += nested[0].length; + remaining = content.slice(pos); + } + return names; +} + /** * Extract middleware wrapper chain from a route handler file. * Detects patterns like: export const POST = withA(withB(withC(handler))) @@ -23,20 +43,123 @@ export function extractMiddlewareChain(content: string): { chain: string[]; meth const method = mwMatch[1] ?? 'default'; const firstWrapper = mwMatch[2]; const chain: string[] = [firstWrapper]; - let pos = mwMatch.index + mwMatch[0].length; - const nestedPattern = /^\s*(\w+)\s*\(/; - let remaining = content.slice(pos); - let nestedMatch; - while ((nestedMatch = nestedPattern.exec(remaining)) !== null) { - const name = nestedMatch[1]; - if (MIDDLEWARE_STOP_KEYWORDS.has(name)) break; - chain.push(name); - pos += nestedMatch[0].length; - remaining = content.slice(pos); - } + chain.push(...walkNestedWrappers(content, mwMatch.index + mwMatch[0].length)); if (chain.length >= 2 || (chain.length === 1 && /^with[A-Z]/.test(chain[0]))) { return { chain, method }; } } return undefined; } + +// --------------------------------------------------------------------------- +// Next.js project-level middleware.ts extraction +// --------------------------------------------------------------------------- + +export interface NextjsMiddlewareConfig { + matchers: string[]; + exportedName: string; + wrappedFunctions: string[]; +} + +/** + * Parse a Next.js project-level middleware.ts file and extract: + * - config.matcher patterns (string or string[]) + * - the exported middleware function name + * - wrapper composition (e.g. chain([withAuth, withI18n])) + */ +export function extractNextjsMiddlewareConfig(content: string): NextjsMiddlewareConfig | undefined { + const matchers: string[] = []; + const matcherArrayRe = /config\s*=\s*\{[^}]*matcher\s*:\s*\[([^\]]*)\]/s; + const matcherStringRe = /config\s*=\s*\{[^}]*matcher\s*:\s*(['"`])([^'"`]+)\1/s; + const arrMatch = matcherArrayRe.exec(content); + if (arrMatch) { + const items = arrMatch[1]; + const strRe = /(['"`])((?:[^'"`\\\\]|\\\\.)*)\1/g; + let m; + while ((m = strRe.exec(items)) !== null) { + matchers.push(m[2]); + } + } else { + const strMatch = matcherStringRe.exec(content); + if (strMatch) { + matchers.push(strMatch[2]); + } + } + + let exportedName = 'middleware'; + const isNamedMw = /export\s+(?:async\s+)?function\s+middleware\b/.test(content); + const isConstMw = /export\s+const\s+middleware\s*=/.test(content); + const defaultFunctionMatch = /export\s+default\s+(?:async\s+)?function(?:\s+(\w+))?/.exec(content); + const defaultIdentifierMatch = /export\s+default\s+(?!function\b)(\w+)/.exec(content); + + if (!isNamedMw && !isConstMw) { + if (defaultFunctionMatch) { + exportedName = defaultFunctionMatch[1] ?? 'middleware'; + } else if (defaultIdentifierMatch) { + exportedName = defaultIdentifierMatch[1]; + } + } + + // --- wrapper composition --- + const wrappedFunctions: string[] = []; + // Pattern: chain([fn1, fn2]) or compose(fn1, fn2) + const chainRe = /(?:chain|compose)\s*\(\s*\[([^\]]+)\]/; + const chainMatch = chainRe.exec(content); + if (chainMatch) { + const fns = chainMatch[1].split(',').map(s => s.trim()).filter(Boolean); + wrappedFunctions.push(...fns); + } + // Pattern: export default withA(withB(handler)) + const wrapperRe = /export\s+default\s+(\w+)\s*\(/; + const wrapperMatch = wrapperRe.exec(content); + if (wrapperMatch && wrappedFunctions.length === 0) { + const name = wrapperMatch[1]; + if (name !== 'function' && name !== 'async') { + wrappedFunctions.push(name); + wrappedFunctions.push(...walkNestedWrappers(content, wrapperMatch.index + wrapperMatch[0].length)); + } + } + + if (!COMPOSER_NAMES.has(exportedName) && !wrappedFunctions.includes(exportedName)) { + wrappedFunctions.unshift(exportedName); + } + + const hasExport = isNamedMw || isConstMw || !!defaultFunctionMatch || !!defaultIdentifierMatch; + if (!hasExport && matchers.length === 0 && wrappedFunctions.length === 0) return undefined; + + return { matchers, exportedName, wrappedFunctions }; +} + +/** Pre-compiled matcher for efficient per-route testing. */ +export type CompiledMatcher = + | { type: 'prefix'; prefix: string } + | { type: 'regex'; re: RegExp } + | { type: 'exact'; value: string }; + +/** + * Compile a Next.js middleware matcher pattern into a reusable matcher. + * Call once per pattern, then use compiledMatcherMatchesRoute per route. + */ +export function compileMatcher(matcher: string): CompiledMatcher | null { + const paramWild = matcher.replace(/\/:path\*$/, ''); + if (paramWild !== matcher) return { type: 'prefix', prefix: paramWild }; + if (matcher.includes('(')) { + try { return { type: 'regex', re: new RegExp('^' + matcher + '$') }; } + catch { return null; } + } + return { type: 'exact', value: matcher }; +} + +/** Test a route URL against a pre-compiled matcher. */ +export function compiledMatcherMatchesRoute(cm: CompiledMatcher, routeURL: string): boolean { + switch (cm.type) { + case 'prefix': return routeURL === cm.prefix || routeURL.startsWith(cm.prefix + '/'); + case 'regex': return cm.re.test(routeURL); + case 'exact': return routeURL === cm.value; + } +} + +export function middlewareMatcherMatchesRoute(matcher: string, routeURL: string): boolean { + const cm = compileMatcher(matcher); + return cm ? compiledMatcherMatchesRoute(cm, routeURL) : false; +} diff --git a/gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts b/gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts new file mode 100644 index 0000000000..102a422893 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts @@ -0,0 +1,13 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const token = request.headers.get('Authorization'); + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + return NextResponse.next(); +} + +export const config = { + matcher: ['/api/:path*'], +}; diff --git a/gitnexus/test/integration/resolvers/route-mapping.test.ts b/gitnexus/test/integration/resolvers/route-mapping.test.ts index e363856a78..8baa6128a0 100644 --- a/gitnexus/test/integration/resolvers/route-mapping.test.ts +++ b/gitnexus/test/integration/resolvers/route-mapping.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -51,4 +51,33 @@ describe('Next.js route mapping', () => { expect(dynamicFetch).toBeDefined(); expect(dynamicFetch!.target).toBe('/api/organizations/[slug]/grants'); }); + + it('links project-level middleware.ts to matching API routes', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const grants = routes.find(r => r.name === '/api/grants'); + expect(grants).toBeDefined(); + expect(grants!.properties.middleware).toBeDefined(); + expect(grants!.properties.middleware).toContain('middleware'); + }); + + it('links middleware to all routes matching the matcher pattern', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const apiRoutes = routes.filter(r => (r.name as string).startsWith('/api/')); + expect(apiRoutes.length).toBeGreaterThanOrEqual(2); + for (const route of apiRoutes) { + expect(route.properties.middleware).toBeDefined(); + expect((route.properties.middleware as string[]).length).toBeGreaterThan(0); + } + }); + + it('does not link middleware to routes outside the matcher pattern', () => { + const routes = getNodesByLabelFull(result, 'Route'); + const nonApiRoutes = routes.filter(r => !(r.name as string).startsWith('/api')); + for (const route of nonApiRoutes) { + const mw = route.properties.middleware as string[] | undefined; + if (mw) { + expect(mw).not.toContain('middleware'); + } + } + }); }); diff --git a/gitnexus/test/unit/route-tool-detection.test.ts b/gitnexus/test/unit/route-tool-detection.test.ts index 105cfdbff1..8715073d01 100644 --- a/gitnexus/test/unit/route-tool-detection.test.ts +++ b/gitnexus/test/unit/route-tool-detection.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; import { nextjsFileToRouteURL, normalizeFetchURL, routeMatches } from '../../src/core/ingestion/route-extractors/nextjs.js'; import { phpFileToRouteURL } from '../../src/core/ingestion/route-extractors/php.js'; -import { extractMiddlewareChain } from '../../src/core/ingestion/route-extractors/middleware.js'; +import { extractMiddlewareChain, extractNextjsMiddlewareConfig, middlewareMatcherMatchesRoute } from '../../src/core/ingestion/route-extractors/middleware.js'; import { detectStatusCode, extractResponseShapes, extractPHPResponseShapes } from '../../src/core/ingestion/route-extractors/response-shapes.js'; // --------------------------------------------------------------------------- @@ -469,6 +469,137 @@ describe('error response shape separation', () => { }); }); +// --------------------------------------------------------------------------- +// Next.js project-level middleware config extraction +// --------------------------------------------------------------------------- + +describe('extractNextjsMiddlewareConfig', () => { + it('extracts matcher array and named function export', () => { + const content = ` + import { NextResponse, type NextRequest } from "next/server"; + export function middleware(request: NextRequest) { + return NextResponse.next(); + } + export const config = { + matcher: ["/api/:path*", "/dashboard/:path*"], + }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result).toBeDefined(); + expect(result!.matchers).toEqual(['/api/:path*', '/dashboard/:path*']); + expect(result!.exportedName).toBe('middleware'); + }); + + it('extracts single string matcher', () => { + const content = ` + export function middleware(req) {} + export const config = { matcher: '/dashboard/:path*' }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result!.matchers).toEqual(['/dashboard/:path*']); + }); + + it('extracts default export name as wrappedFunction', () => { + const content = ` + import { auth } from '@/lib/auth'; + export default auth; + export const config = { matcher: ['/api/:path*'] }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result!.exportedName).toBe('auth'); + expect(result!.wrappedFunctions).toContain('auth'); + expect(result!.matchers).toEqual(['/api/:path*']); + }); + + it('extracts chain composition', () => { + const content = ` + export default chain([withAuth, withI18n]); + export const config = { matcher: ['/((?!api|_next).*)'] }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result!.wrappedFunctions).toEqual(['withAuth', 'withI18n']); + }); + + it('extracts nested wrapper composition from default export', () => { + const content = ` + export default withRateLimit(withAuth(handler)); + export const config = { matcher: ['/api/:path*'] }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result!.wrappedFunctions).toEqual(['withRateLimit', 'withAuth']); + }); + + it('extracts regex-style negative lookahead matcher', () => { + const content = ` + export function middleware(req) {} + export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\\\..*$).*)"], + }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result!.matchers).toHaveLength(1); + expect(result!.matchers[0]).toContain('(?!api|_next'); + }); + + it('treats middleware without config.matcher as match-all', () => { + const content = `export function middleware(req) { return NextResponse.next(); }`; + const result = extractNextjsMiddlewareConfig(content); + expect(result).toBeDefined(); + expect(result!.matchers).toEqual([]); + expect(result!.exportedName).toBe('middleware'); + }); + + it('detects arrow function const export', () => { + const content = ` + export const middleware = (req) => { + return NextResponse.next(); + }; + export const config = { matcher: ['/dashboard/:path*'] }; + `; + const result = extractNextjsMiddlewareConfig(content); + expect(result).toBeDefined(); + expect(result!.exportedName).toBe('middleware'); + expect(result!.matchers).toEqual(['/dashboard/:path*']); + }); + + it('handles export default function middleware(...)', () => { + const content = `export default function middleware(req) { return NextResponse.next(); }`; + const result = extractNextjsMiddlewareConfig(content); + expect(result).toBeDefined(); + expect(result!.exportedName).toBe('middleware'); + }); +}); + +// --------------------------------------------------------------------------- +// Middleware matcher to route matching +// --------------------------------------------------------------------------- + +describe('middlewareMatcherMatchesRoute', () => { + it('matches prefix pattern with :path*', () => { + expect(middlewareMatcherMatchesRoute('/api/:path*', '/api/users')).toBe(true); + expect(middlewareMatcherMatchesRoute('/api/:path*', '/api/auth/login')).toBe(true); + expect(middlewareMatcherMatchesRoute('/api/:path*', '/api')).toBe(true); + }); + + it('does not match unrelated routes with :path*', () => { + expect(middlewareMatcherMatchesRoute('/dashboard/:path*', '/api/users')).toBe(false); + expect(middlewareMatcherMatchesRoute('/api/:path*', '/v1/users')).toBe(false); + }); + + it('matches exact route', () => { + expect(middlewareMatcherMatchesRoute('/login', '/login')).toBe(true); + expect(middlewareMatcherMatchesRoute('/login', '/login/callback')).toBe(false); + }); + + it('matches regex-style negative lookahead pattern', () => { + const matcher = '/((?!api|_next).*)'; + expect(middlewareMatcherMatchesRoute(matcher, '/dashboard')).toBe(true); + expect(middlewareMatcherMatchesRoute(matcher, '/settings/profile')).toBe(true); + expect(middlewareMatcherMatchesRoute(matcher, '/api')).toBe(false); + expect(middlewareMatcherMatchesRoute(matcher, '/api/users')).toBe(false); + }); +}); + describe('PHP response shape extraction', () => { it('extracts keys from short array syntax', () => { const content = `echo json_encode(['success' => true, 'data' => $items], JSON_UNESCAPED_UNICODE);`;