feat(routes): link Next.js project-level middleware.ts to routes (#504)

This commit is contained in:
marxo126 2026-03-25 23:07:58 +01:00 • committed by GitHub
parent 7349a43a6d
commit df9e995e4a
5 changed files with 351 additions and 14 deletions

View file

@ -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<typeof m> => 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 <form action="/path"> and AJAX url patterns
// Scan HTML/template files for <form action="/path"> and AJAX url patterns
// Skip .php — already parsed by tree-sitter with http_client/fetch queries

View file

@ -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;
}

View file

@ -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*'],
};

View file

@ -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');
}
}
});
});

View file

@ -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);`;