mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat(routes): link Next.js project-level middleware.ts to routes (#504)
This commit is contained in:
parent
7349a43a6d
commit
df9e995e4a
5 changed files with 351 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
13
gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/nextjs-route-mapping/middleware.ts
vendored
Normal 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*'],
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue