mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
ed176527e2
14 changed files with 1133 additions and 457 deletions
|
|
@ -18,7 +18,11 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js';
|
|||
|
||||
// Language support
|
||||
export { SupportedLanguages } from './languages.js';
|
||||
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
|
||||
export {
|
||||
getLanguageFromFilename,
|
||||
getSyntaxLanguageFromFilename,
|
||||
isBladeTemplateFilename,
|
||||
} from './language-detection.js';
|
||||
export type { MroStrategy } from './mro-strategy.js';
|
||||
|
||||
// Pipeline progress
|
||||
|
|
|
|||
|
|
@ -56,11 +56,21 @@ for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel Blade templates are source templates whose filename convention ends
|
||||
* in `.blade.php`. They may contain PHP snippets, but the full file is not a
|
||||
* pure PHP translation unit and must not enter the generic PHP provider path.
|
||||
*/
|
||||
export const isBladeTemplateFilename = (filePath: string): boolean =>
|
||||
filePath.replace(/\\/g, '/').toLowerCase().endsWith('.blade.php');
|
||||
|
||||
/**
|
||||
* Map file extension to SupportedLanguage enum.
|
||||
* Returns null if the file extension is not recognized.
|
||||
*/
|
||||
export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => {
|
||||
if (isBladeTemplateFilename(filename)) return null;
|
||||
|
||||
// Fast path: check the extension map
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot >= 0) {
|
||||
|
|
@ -138,6 +148,8 @@ const AUXILIARY_BASENAME_MAP: Record<string, string> = {
|
|||
* Returns 'text' for unrecognised files.
|
||||
*/
|
||||
export const getSyntaxLanguageFromFilename = (filePath: string): string => {
|
||||
if (isBladeTemplateFilename(filePath)) return 'markup';
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (lang) return SYNTAX_MAP[lang];
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -283,10 +283,20 @@ const IGNORED_FILES = new Set([
|
|||
// deterministic results independent of per-repo config.
|
||||
export const shouldIgnorePath = (filePath: string): boolean => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const normalizedPathLower = normalizedPath.toLowerCase();
|
||||
const parts = normalizedPath.split('/');
|
||||
const fileName = parts[parts.length - 1];
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
|
||||
// Laravel compiles Blade templates into generated PHP cache files under
|
||||
// storage/framework/views. Source templates live in resources/views and are
|
||||
// handled separately; compiled cache should not become source-of-truth. Keep
|
||||
// storage/framework/cache parseable unless a separate warning source is proven:
|
||||
// Laravel route/config cache files are ordinary generated PHP, not Blade.
|
||||
if (/(^|\/)storage\/framework\/views(\/|$)/.test(normalizedPathLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any path segment is in the hardcoded ignore list.
|
||||
for (const part of parts) {
|
||||
if (DEFAULT_IGNORE_LIST.has(part)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as path from 'node:path';
|
||||
import { isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
import type { HttpLanguagePlugin } from './types.js';
|
||||
import { JAVA_HTTP_PLUGIN } from './java.js';
|
||||
import { GO_HTTP_PLUGIN } from './go.js';
|
||||
|
|
@ -45,6 +46,7 @@ export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}';
|
|||
* or `undefined` if the extension is not registered.
|
||||
*/
|
||||
export function getPluginForFile(rel: string): HttpLanguagePlugin | undefined {
|
||||
if (isBladeTemplateFilename(rel)) return undefined;
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
return REGISTRY[ext];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* 4. Run `tsc --noEmit` to verify
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { SupportedLanguages, isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
|
||||
import { typescriptProvider, javascriptProvider } from './typescript.js';
|
||||
|
|
@ -61,6 +61,8 @@ for (const provider of Object.values(providers)) {
|
|||
/** Look up a language provider from a file path by extension.
|
||||
* Returns null if the file extension is not recognized. */
|
||||
export function getProviderForFile(filePath: string): LanguageProvider | null {
|
||||
if (isBladeTemplateFilename(filePath)) return null;
|
||||
|
||||
const lastDot = filePath.lastIndexOf('.');
|
||||
const ext = lastDot >= 0 ? filePath.slice(lastDot).toLowerCase() : '';
|
||||
const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
import { isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
import { nextjsFileToRouteURL, normalizeFetchURL } from '../route-extractors/nextjs.js';
|
||||
import { expoFileToRouteURL } from '../route-extractors/expo.js';
|
||||
import { phpFileToRouteURL } from '../route-extractors/php.js';
|
||||
|
|
@ -47,6 +48,89 @@ export interface RoutesOutput {
|
|||
routeRegistry: Map<string, RouteEntry>;
|
||||
}
|
||||
|
||||
export interface TemplateFetchCall {
|
||||
filePath: string;
|
||||
fetchURL: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
const TEMPLATE_URL_PATTERNS: readonly RegExp[] = [
|
||||
/\b(?:action|href)\s*=\s*["']([^"']+)["']/gi,
|
||||
/\burl\s*:\s*["']([^"']+)["'](?!\s*\+)/g,
|
||||
// Laravel asset() points at static assets, not application routes; keep it
|
||||
// out of route matching so asset paths cannot collide with real route URLs.
|
||||
/\{\{[\s\S]{0,200}?\burl\(\s*["']([^"']+)["']\s*\)[\s\S]{0,200}?\}\}/g,
|
||||
/\{!![\s\S]{0,200}?\burl\(\s*["']([^"']+)["']\s*\)[\s\S]{0,200}?!\}/g,
|
||||
];
|
||||
|
||||
const TEMPLATE_NAMED_ROUTE_PATTERNS: readonly RegExp[] = [
|
||||
// Parameterless Laravel route('name') helpers can be resolved from extracted
|
||||
// route names. Parameterized helpers are intentionally deferred because they
|
||||
// require binding runtime values onto route placeholders.
|
||||
/\{\{[\s\S]{0,200}?\broute\(\s*["']([^"']+)["']\s*\)[\s\S]{0,200}?\}\}/g,
|
||||
/\{!![\s\S]{0,200}?\broute\(\s*["']([^"']+)["']\s*\)[\s\S]{0,200}?!\}/g,
|
||||
];
|
||||
|
||||
function hasRouteParameters(routeUrl: string): boolean {
|
||||
return /\{[^}]+\}/.test(routeUrl);
|
||||
}
|
||||
|
||||
export const isTemplateRouteCandidate = (filePath: string): boolean => {
|
||||
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||
return (
|
||||
normalized.endsWith('.html') ||
|
||||
normalized.endsWith('.htm') ||
|
||||
normalized.endsWith('.ejs') ||
|
||||
normalized.endsWith('.hbs') ||
|
||||
isBladeTemplateFilename(normalized)
|
||||
);
|
||||
};
|
||||
|
||||
export function extractTemplateStaticFetchCalls(
|
||||
filePath: string,
|
||||
content: string,
|
||||
namedRouteUrls: ReadonlyMap<string, string> = new Map(),
|
||||
): TemplateFetchCall[] {
|
||||
const calls: TemplateFetchCall[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pattern of TEMPLATE_URL_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const normalized = normalizeFetchURL(match[1]);
|
||||
if (!normalized) continue;
|
||||
if (seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
calls.push({ filePath, fetchURL: normalized, lineNumber: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of TEMPLATE_NAMED_ROUTE_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const routeUrl = namedRouteUrls.get(match[1]);
|
||||
if (!routeUrl) continue;
|
||||
if (hasRouteParameters(routeUrl)) continue;
|
||||
const normalized = normalizeFetchURL(routeUrl);
|
||||
if (!normalized) continue;
|
||||
if (seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
calls.push({ filePath, fetchURL: normalized, lineNumber: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string {
|
||||
const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
|
||||
const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
|
||||
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
|
||||
return joined.replace(/\/+/g, '/') || '/';
|
||||
}
|
||||
|
||||
export const routesPhase: PipelinePhase<RoutesOutput> = {
|
||||
name: 'routes',
|
||||
deps: ['parse'],
|
||||
|
|
@ -111,6 +195,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
|
||||
const ensureSlash = (path: string) => (path.startsWith('/') ? path : '/' + path);
|
||||
let duplicateRoutes = 0;
|
||||
const namedRouteRegistry = new Map<string, string>();
|
||||
const addRoute = (url: string, entry: RouteEntry) => {
|
||||
if (routeRegistry.has(url)) {
|
||||
duplicateRoutes++;
|
||||
|
|
@ -120,10 +205,14 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
};
|
||||
for (const route of allExtractedRoutes) {
|
||||
if (!route.routePath) continue;
|
||||
addRoute(ensureSlash(route.routePath), {
|
||||
const routeUrl = normalizeExtractedRoutePath(route.routePath, route.prefix);
|
||||
addRoute(routeUrl, {
|
||||
filePath: route.filePath,
|
||||
source: 'framework-route',
|
||||
});
|
||||
if (route.routeName && !namedRouteRegistry.has(route.routeName)) {
|
||||
namedRouteRegistry.set(route.routeName, routeUrl);
|
||||
}
|
||||
}
|
||||
for (const dr of allDecoratorRoutes) {
|
||||
addRoute(ensureSlash(dr.routePath), {
|
||||
|
|
@ -233,29 +322,15 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
}
|
||||
}
|
||||
|
||||
// Scan HTML/template files for form action and AJAX url patterns
|
||||
const htmlCandidates = allPaths.filter(
|
||||
(p) =>
|
||||
p.endsWith('.html') ||
|
||||
p.endsWith('.htm') ||
|
||||
p.endsWith('.ejs') ||
|
||||
p.endsWith('.hbs') ||
|
||||
p.endsWith('.blade.php'),
|
||||
);
|
||||
// Scan HTML/template files for safe static form/link/AJAX URL patterns.
|
||||
// Blade stays template-only here; it must not re-enter PHP provider paths.
|
||||
const htmlCandidates = allPaths.filter(isTemplateRouteCandidate);
|
||||
if (htmlCandidates.length > 0 && routeRegistry.size > 0) {
|
||||
const htmlContents = await readFileContents(ctx.repoPath, htmlCandidates);
|
||||
const htmlPatterns = [/action=["']([^"']+)["']/g, /url:\s*["']([^"']+)["']/g];
|
||||
for (const [filePath, content] of htmlContents) {
|
||||
for (const pattern of htmlPatterns) {
|
||||
pattern.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const normalized = normalizeFetchURL(match[1]);
|
||||
if (normalized) {
|
||||
allFetchCalls.push({ filePath, fetchURL: normalized, lineNumber: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
allFetchCalls.push(
|
||||
...extractTemplateStaticFetchCalls(filePath, content, namedRouteRegistry),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
495
gitnexus/src/core/ingestion/route-extractors/laravel.ts
Normal file
495
gitnexus/src/core/ingestion/route-extractors/laravel.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import type Parser from 'tree-sitter';
|
||||
import { extractStringContent, findDescendant, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
|
||||
export interface ExtractedRoute {
|
||||
filePath: string;
|
||||
httpMethod: string;
|
||||
routePath: string | null;
|
||||
routeName: string | null;
|
||||
controllerName: string | null;
|
||||
methodName: string | null;
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
interface RouteGroupContext {
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
namePrefix: string | null;
|
||||
controller: string | null;
|
||||
}
|
||||
|
||||
const ROUTE_HTTP_METHODS = new Set([
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'any',
|
||||
'match',
|
||||
]);
|
||||
|
||||
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
|
||||
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
|
||||
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
|
||||
|
||||
/** Check if node is a scoped_call_expression with object 'Route' */
|
||||
function isRouteStaticCall(node: SyntaxNode): boolean {
|
||||
if (node.type !== 'scoped_call_expression') return false;
|
||||
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
|
||||
return obj?.text === 'Route';
|
||||
}
|
||||
|
||||
/** Get the method name from a scoped_call_expression or member_call_expression */
|
||||
function getCallMethodName(node: SyntaxNode): string | null {
|
||||
const nameNode =
|
||||
node.childForFieldName?.('name') ?? node.children?.find((c: SyntaxNode) => c.type === 'name');
|
||||
return nameNode?.text ?? null;
|
||||
}
|
||||
|
||||
/** Get the arguments node from a call expression */
|
||||
function getArguments(node: SyntaxNode): SyntaxNode | null {
|
||||
return node.children?.find((c: SyntaxNode) => c.type === 'arguments') ?? null;
|
||||
}
|
||||
|
||||
/** Find the closure body inside arguments */
|
||||
function findClosureBody(argsNode: SyntaxNode | null): SyntaxNode | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') {
|
||||
for (const inner of child.children ?? []) {
|
||||
if (inner.type === 'anonymous_function' || inner.type === 'arrow_function') {
|
||||
return (
|
||||
inner.childForFieldName?.('body') ??
|
||||
inner.children?.find((c: SyntaxNode) => c.type === 'compound_statement') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'anonymous_function' || child.type === 'arrow_function') {
|
||||
return (
|
||||
child.childForFieldName?.('body') ??
|
||||
child.children?.find((c: SyntaxNode) => c.type === 'compound_statement') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract first string argument from arguments node */
|
||||
function extractFirstStringArg(argsNode: SyntaxNode | null): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
return extractStringContent(target);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract middleware from arguments — handles string or array */
|
||||
function extractMiddlewareArg(argsNode: SyntaxNode | null): string[] {
|
||||
if (!argsNode) return [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
const val = extractStringContent(target);
|
||||
return val ? [val] : [];
|
||||
}
|
||||
if (target.type === 'array_creation_expression') {
|
||||
const items: string[] = [];
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') {
|
||||
const str = el.children?.find(
|
||||
(c: SyntaxNode) => c.type === 'string' || c.type === 'encapsed_string',
|
||||
);
|
||||
const val = str ? extractStringContent(str) : null;
|
||||
if (val) items.push(val);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Extract Controller::class from arguments */
|
||||
function extractClassArg(argsNode: SyntaxNode | null): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'class_constant_access_expression') {
|
||||
return target.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function joinRouteName(prefix: string | null, name: string | null): string | null {
|
||||
if (!name) return null;
|
||||
return prefix ? `${prefix}${name}` : name;
|
||||
}
|
||||
|
||||
function routeNameBaseFromPath(routePath: string | null): string | null {
|
||||
const base = routePath
|
||||
?.trim()
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.replace(/\//g, '.');
|
||||
return base || null;
|
||||
}
|
||||
|
||||
function appendResourceActionName(base: string | null, action: string): string | null {
|
||||
if (!base) return null;
|
||||
return base.endsWith('.') ? `${base}${action}` : `${base}.${action}`;
|
||||
}
|
||||
|
||||
/** Extract controller class name from common Laravel handler argument shapes. */
|
||||
function extractControllerTarget(argsNode: SyntaxNode | null): {
|
||||
controller: string | null;
|
||||
method: string | null;
|
||||
bareMethod: string | null;
|
||||
} {
|
||||
if (!argsNode) return { controller: null, method: null, bareMethod: null };
|
||||
|
||||
const args: (SyntaxNode | undefined)[] = [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') args.push(child.children?.[0]);
|
||||
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
|
||||
}
|
||||
|
||||
// Second arg is the handler
|
||||
const handlerNode = args[1];
|
||||
if (!handlerNode) return { controller: null, method: null, bareMethod: null };
|
||||
|
||||
// Array syntax: [UserController::class, 'index']
|
||||
if (handlerNode.type === 'array_creation_expression') {
|
||||
let controller: string | null = null;
|
||||
let method: string | null = null;
|
||||
const elements: SyntaxNode[] = [];
|
||||
for (const el of handlerNode.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') elements.push(el);
|
||||
}
|
||||
if (elements[0]) {
|
||||
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
|
||||
if (classAccess) {
|
||||
controller = classAccess.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
if (elements[1]) {
|
||||
const str = findDescendant(elements[1], 'string');
|
||||
method = str ? extractStringContent(str) : null;
|
||||
}
|
||||
return { controller, method, bareMethod: null };
|
||||
}
|
||||
|
||||
// String syntax: 'UserController@index'. A bare string such as 'index'
|
||||
// becomes a method only when a surrounding Route::controller(...) group
|
||||
// supplies the controller class.
|
||||
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
|
||||
const text = extractStringContent(handlerNode);
|
||||
if (text?.includes('@')) {
|
||||
const [controller, method] = text.split('@');
|
||||
return { controller, method, bareMethod: null };
|
||||
}
|
||||
if (text) return { controller: null, method: null, bareMethod: text };
|
||||
}
|
||||
|
||||
// Class reference: UserController::class (invokable controller)
|
||||
if (handlerNode.type === 'class_constant_access_expression') {
|
||||
const controller =
|
||||
handlerNode.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
return { controller, method: '__invoke', bareMethod: null };
|
||||
}
|
||||
|
||||
return { controller: null, method: null, bareMethod: null };
|
||||
}
|
||||
|
||||
interface ChainedRouteCall {
|
||||
isRouteFacade: boolean;
|
||||
terminalMethod: string;
|
||||
attributes: { method: string; argsNode: SyntaxNode | null }[];
|
||||
terminalArgs: SyntaxNode | null;
|
||||
node: SyntaxNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn)
|
||||
*/
|
||||
function unwrapRouteChain(node: SyntaxNode): ChainedRouteCall | null {
|
||||
if (node.type !== 'member_call_expression') return null;
|
||||
|
||||
const terminalMethod = getCallMethodName(node);
|
||||
if (!terminalMethod) return null;
|
||||
|
||||
const terminalArgs = getArguments(node);
|
||||
const attributes: { method: string; argsNode: SyntaxNode | null }[] = [];
|
||||
|
||||
let current = node.children?.[0];
|
||||
|
||||
while (current) {
|
||||
if (current.type === 'member_call_expression') {
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
current = current.children?.[0];
|
||||
} else if (current.type === 'scoped_call_expression') {
|
||||
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
|
||||
if (obj?.text !== 'Route') return null;
|
||||
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
|
||||
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */
|
||||
function parseArrayGroupArgs(argsNode: SyntaxNode | null): RouteGroupContext {
|
||||
const ctx: RouteGroupContext = {
|
||||
middleware: [],
|
||||
prefix: null,
|
||||
namePrefix: null,
|
||||
controller: null,
|
||||
};
|
||||
if (!argsNode) return ctx;
|
||||
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'array_creation_expression') {
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type !== 'array_element_initializer') continue;
|
||||
const children = el.children ?? [];
|
||||
const arrowIdx = children.findIndex((c: SyntaxNode) => c.type === '=>');
|
||||
if (arrowIdx === -1) continue;
|
||||
const key = extractStringContent(children[arrowIdx - 1]);
|
||||
const val = children[arrowIdx + 1];
|
||||
if (key === 'middleware') {
|
||||
if (val?.type === 'string') {
|
||||
const s = extractStringContent(val);
|
||||
if (s) ctx.middleware.push(s);
|
||||
} else if (val?.type === 'array_creation_expression') {
|
||||
for (const item of val.children ?? []) {
|
||||
if (item.type === 'array_element_initializer') {
|
||||
const str = item.children?.find((c: SyntaxNode) => c.type === 'string');
|
||||
const s = str ? extractStringContent(str) : null;
|
||||
if (s) ctx.middleware.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key === 'prefix') {
|
||||
ctx.prefix = extractStringContent(val) ?? null;
|
||||
} else if (key === 'as' || key === 'name') {
|
||||
ctx.namePrefix = extractStringContent(val) ?? null;
|
||||
} else if (key === 'controller') {
|
||||
if (val?.type === 'class_constant_access_expression') {
|
||||
ctx.controller = val.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRoute[] {
|
||||
const routes: ExtractedRoute[] = [];
|
||||
|
||||
function resolveStack(stack: RouteGroupContext[]): {
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
namePrefix: string | null;
|
||||
controller: string | null;
|
||||
} {
|
||||
const middleware: string[] = [];
|
||||
let prefix: string | null = null;
|
||||
let namePrefix: string | null = null;
|
||||
let controller: string | null = null;
|
||||
for (const ctx of stack) {
|
||||
middleware.push(...ctx.middleware);
|
||||
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
|
||||
if (ctx.namePrefix)
|
||||
namePrefix = namePrefix ? `${namePrefix}${ctx.namePrefix}` : ctx.namePrefix;
|
||||
if (ctx.controller) controller = ctx.controller;
|
||||
}
|
||||
return { middleware, prefix, namePrefix, controller };
|
||||
}
|
||||
|
||||
function emitRoute(
|
||||
httpMethod: string,
|
||||
argsNode: SyntaxNode | null,
|
||||
lineNumber: number,
|
||||
groupStack: RouteGroupContext[],
|
||||
chainAttrs: { method: string; argsNode: SyntaxNode | null }[],
|
||||
) {
|
||||
const effective = resolveStack(groupStack);
|
||||
let routeName: string | null = null;
|
||||
|
||||
for (const attr of chainAttrs) {
|
||||
if (attr.method === 'middleware')
|
||||
effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') {
|
||||
const p = extractFirstStringArg(attr.argsNode);
|
||||
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
|
||||
}
|
||||
if (attr.method === 'controller') {
|
||||
const cls = extractClassArg(attr.argsNode);
|
||||
if (cls) effective.controller = cls;
|
||||
}
|
||||
if (attr.method === 'name') {
|
||||
routeName = joinRouteName(effective.namePrefix, extractFirstStringArg(attr.argsNode));
|
||||
}
|
||||
}
|
||||
|
||||
const routePath = extractFirstStringArg(argsNode);
|
||||
|
||||
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
|
||||
const routeNameBase =
|
||||
routeName ?? joinRouteName(effective.namePrefix, routeNameBaseFromPath(routePath));
|
||||
for (const action of actions) {
|
||||
routes.push({
|
||||
filePath,
|
||||
httpMethod,
|
||||
routePath,
|
||||
routeName: appendResourceActionName(routeNameBase, action),
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: action,
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
routes.push({
|
||||
filePath,
|
||||
httpMethod,
|
||||
routePath,
|
||||
routeName,
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: target.method ?? (effective.controller ? target.bareMethod : null),
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative traversal using an explicit stack to avoid V8 call stack overflow
|
||||
// on deeply nested ASTs (e.g. Go stdlib, large Grafana components).
|
||||
// Each frame tracks the node and a snapshot of the group stack at that depth.
|
||||
interface WalkFrame {
|
||||
node: SyntaxNode;
|
||||
groupSnapshot: RouteGroupContext[];
|
||||
}
|
||||
|
||||
const walkStack: WalkFrame[] = [{ node: tree.rootNode, groupSnapshot: [] }];
|
||||
|
||||
while (walkStack.length > 0) {
|
||||
const { node, groupSnapshot } = walkStack.pop()!;
|
||||
|
||||
// Case 1: Simple Route::get(...), Route::post(...), etc.
|
||||
if (isRouteStaticCall(node)) {
|
||||
const method = getCallMethodName(node);
|
||||
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
|
||||
emitRoute(method, getArguments(node), node.startPosition.row, groupSnapshot, []);
|
||||
continue;
|
||||
}
|
||||
if (method === 'group') {
|
||||
const argsNode = getArguments(node);
|
||||
const groupCtx = parseArrayGroupArgs(argsNode);
|
||||
const body = findClosureBody(argsNode);
|
||||
if (body) {
|
||||
const childSnapshot = [...groupSnapshot, groupCtx];
|
||||
const children = body.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot: childSnapshot });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...)
|
||||
const chain = unwrapRouteChain(node);
|
||||
if (chain) {
|
||||
if (chain.terminalMethod === 'group') {
|
||||
const groupCtx: RouteGroupContext = {
|
||||
middleware: [],
|
||||
prefix: null,
|
||||
namePrefix: null,
|
||||
controller: null,
|
||||
};
|
||||
for (const attr of chain.attributes) {
|
||||
if (attr.method === 'middleware')
|
||||
groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
|
||||
if (attr.method === 'name') groupCtx.namePrefix = extractFirstStringArg(attr.argsNode);
|
||||
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
|
||||
}
|
||||
const body = findClosureBody(chain.terminalArgs);
|
||||
if (body) {
|
||||
const childSnapshot = [...groupSnapshot, groupCtx];
|
||||
const children = body.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot: childSnapshot });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
ROUTE_HTTP_METHODS.has(chain.terminalMethod) ||
|
||||
ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)
|
||||
) {
|
||||
emitRoute(
|
||||
chain.terminalMethod,
|
||||
chain.terminalArgs,
|
||||
node.startPosition.row,
|
||||
groupSnapshot,
|
||||
chain.attributes,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const chainedRouteIndex = chain.attributes.findIndex(
|
||||
(attr) => ROUTE_HTTP_METHODS.has(attr.method) || ROUTE_RESOURCE_METHODS.has(attr.method),
|
||||
);
|
||||
if (chainedRouteIndex >= 0) {
|
||||
const routeAttr = chain.attributes[chainedRouteIndex]!;
|
||||
const routeAttrs = [
|
||||
...chain.attributes.slice(0, chainedRouteIndex),
|
||||
...chain.attributes.slice(chainedRouteIndex + 1),
|
||||
{ method: chain.terminalMethod, argsNode: chain.terminalArgs },
|
||||
];
|
||||
emitRoute(
|
||||
routeAttr.method,
|
||||
routeAttr.argsNode,
|
||||
node.startPosition.row,
|
||||
groupSnapshot,
|
||||
routeAttrs,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: push children in reverse so leftmost is processed first
|
||||
const children = node.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
|
@ -53,8 +53,6 @@ import {
|
|||
findObjectLiteralBindingInfo,
|
||||
type EnclosingClassInfo,
|
||||
getLabelFromCaptures,
|
||||
findDescendant,
|
||||
extractStringContent,
|
||||
genericFuncName,
|
||||
inferFunctionLabel,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
|
|
@ -87,8 +85,10 @@ import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/templ
|
|||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { extractParsedFile } from '../scope-extractor-bridge.js';
|
||||
import { extractLaravelRoutes, type ExtractedRoute } from '../route-extractors/laravel.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
export type { ExtractedRoute } from '../route-extractors/laravel.js';
|
||||
// ============================================================================
|
||||
// Types for serializable results
|
||||
// ============================================================================
|
||||
|
|
@ -192,17 +192,6 @@ export interface ExtractedAssignment {
|
|||
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
|
||||
// re-exported at the top of this file.
|
||||
|
||||
export interface ExtractedRoute {
|
||||
filePath: string;
|
||||
httpMethod: string;
|
||||
routePath: string | null;
|
||||
controllerName: string | null;
|
||||
methodName: string | null;
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface ExtractedFetchCall {
|
||||
filePath: string;
|
||||
fetchURL: string;
|
||||
|
|
@ -841,29 +830,6 @@ const processBatch = (
|
|||
return result;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Laravel Route Extraction (procedural AST walk)
|
||||
// ============================================================================
|
||||
|
||||
interface RouteGroupContext {
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
controller: string | null;
|
||||
}
|
||||
|
||||
const ROUTE_HTTP_METHODS = new Set([
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'any',
|
||||
'match',
|
||||
]);
|
||||
|
||||
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
|
||||
|
||||
// Express/Hono method names that register routes
|
||||
const EXPRESS_ROUTE_METHODS = new Set([
|
||||
'get',
|
||||
|
|
@ -925,402 +891,6 @@ const ROUTE_DECORATOR_NAMES = new Set([
|
|||
'DeleteMapping',
|
||||
]);
|
||||
|
||||
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
|
||||
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
|
||||
|
||||
/** Check if node is a scoped_call_expression with object 'Route' */
|
||||
function isRouteStaticCall(node: SyntaxNode): boolean {
|
||||
if (node.type !== 'scoped_call_expression') return false;
|
||||
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
|
||||
return obj?.text === 'Route';
|
||||
}
|
||||
|
||||
/** Get the method name from a scoped_call_expression or member_call_expression */
|
||||
function getCallMethodName(node: SyntaxNode): string | null {
|
||||
const nameNode =
|
||||
node.childForFieldName?.('name') ?? node.children?.find((c: SyntaxNode) => c.type === 'name');
|
||||
return nameNode?.text ?? null;
|
||||
}
|
||||
|
||||
/** Get the arguments node from a call expression */
|
||||
function getArguments(node: SyntaxNode): SyntaxNode | null {
|
||||
return node.children?.find((c: SyntaxNode) => c.type === 'arguments') ?? null;
|
||||
}
|
||||
|
||||
/** Find the closure body inside arguments */
|
||||
function findClosureBody(argsNode: SyntaxNode | null): SyntaxNode | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') {
|
||||
for (const inner of child.children ?? []) {
|
||||
if (inner.type === 'anonymous_function' || inner.type === 'arrow_function') {
|
||||
return (
|
||||
inner.childForFieldName?.('body') ??
|
||||
inner.children?.find((c: SyntaxNode) => c.type === 'compound_statement') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'anonymous_function' || child.type === 'arrow_function') {
|
||||
return (
|
||||
child.childForFieldName?.('body') ??
|
||||
child.children?.find((c: SyntaxNode) => c.type === 'compound_statement') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract first string argument from arguments node */
|
||||
function extractFirstStringArg(argsNode: SyntaxNode | null): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
return extractStringContent(target);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract middleware from arguments — handles string or array */
|
||||
function extractMiddlewareArg(argsNode: SyntaxNode | null): string[] {
|
||||
if (!argsNode) return [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
const val = extractStringContent(target);
|
||||
return val ? [val] : [];
|
||||
}
|
||||
if (target.type === 'array_creation_expression') {
|
||||
const items: string[] = [];
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') {
|
||||
const str = el.children?.find(
|
||||
(c: SyntaxNode) => c.type === 'string' || c.type === 'encapsed_string',
|
||||
);
|
||||
const val = str ? extractStringContent(str) : null;
|
||||
if (val) items.push(val);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Extract Controller::class from arguments */
|
||||
function extractClassArg(argsNode: SyntaxNode | null): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'class_constant_access_expression') {
|
||||
return target.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract controller class name from arguments: [Controller::class, 'method'] or 'Controller@method' */
|
||||
function extractControllerTarget(argsNode: SyntaxNode | null): {
|
||||
controller: string | null;
|
||||
method: string | null;
|
||||
} {
|
||||
if (!argsNode) return { controller: null, method: null };
|
||||
|
||||
const args: (SyntaxNode | undefined)[] = [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') args.push(child.children?.[0]);
|
||||
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
|
||||
}
|
||||
|
||||
// Second arg is the handler
|
||||
const handlerNode = args[1];
|
||||
if (!handlerNode) return { controller: null, method: null };
|
||||
|
||||
// Array syntax: [UserController::class, 'index']
|
||||
if (handlerNode.type === 'array_creation_expression') {
|
||||
let controller: string | null = null;
|
||||
let method: string | null = null;
|
||||
const elements: SyntaxNode[] = [];
|
||||
for (const el of handlerNode.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') elements.push(el);
|
||||
}
|
||||
if (elements[0]) {
|
||||
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
|
||||
if (classAccess) {
|
||||
controller = classAccess.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
if (elements[1]) {
|
||||
const str = findDescendant(elements[1], 'string');
|
||||
method = str ? extractStringContent(str) : null;
|
||||
}
|
||||
return { controller, method };
|
||||
}
|
||||
|
||||
// String syntax: 'UserController@index'
|
||||
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
|
||||
const text = extractStringContent(handlerNode);
|
||||
if (text?.includes('@')) {
|
||||
const [controller, method] = text.split('@');
|
||||
return { controller, method };
|
||||
}
|
||||
}
|
||||
|
||||
// Class reference: UserController::class (invokable controller)
|
||||
if (handlerNode.type === 'class_constant_access_expression') {
|
||||
const controller =
|
||||
handlerNode.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
return { controller, method: '__invoke' };
|
||||
}
|
||||
|
||||
return { controller: null, method: null };
|
||||
}
|
||||
|
||||
interface ChainedRouteCall {
|
||||
isRouteFacade: boolean;
|
||||
terminalMethod: string;
|
||||
attributes: { method: string; argsNode: SyntaxNode | null }[];
|
||||
terminalArgs: SyntaxNode | null;
|
||||
node: SyntaxNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn)
|
||||
*/
|
||||
function unwrapRouteChain(node: SyntaxNode): ChainedRouteCall | null {
|
||||
if (node.type !== 'member_call_expression') return null;
|
||||
|
||||
const terminalMethod = getCallMethodName(node);
|
||||
if (!terminalMethod) return null;
|
||||
|
||||
const terminalArgs = getArguments(node);
|
||||
const attributes: { method: string; argsNode: SyntaxNode | null }[] = [];
|
||||
|
||||
let current = node.children?.[0];
|
||||
|
||||
while (current) {
|
||||
if (current.type === 'member_call_expression') {
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
current = current.children?.[0];
|
||||
} else if (current.type === 'scoped_call_expression') {
|
||||
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
|
||||
if (obj?.text !== 'Route') return null;
|
||||
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
|
||||
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */
|
||||
function parseArrayGroupArgs(argsNode: SyntaxNode | null): RouteGroupContext {
|
||||
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
|
||||
if (!argsNode) return ctx;
|
||||
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'array_creation_expression') {
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type !== 'array_element_initializer') continue;
|
||||
const children = el.children ?? [];
|
||||
const arrowIdx = children.findIndex((c: SyntaxNode) => c.type === '=>');
|
||||
if (arrowIdx === -1) continue;
|
||||
const key = extractStringContent(children[arrowIdx - 1]);
|
||||
const val = children[arrowIdx + 1];
|
||||
if (key === 'middleware') {
|
||||
if (val?.type === 'string') {
|
||||
const s = extractStringContent(val);
|
||||
if (s) ctx.middleware.push(s);
|
||||
} else if (val?.type === 'array_creation_expression') {
|
||||
for (const item of val.children ?? []) {
|
||||
if (item.type === 'array_element_initializer') {
|
||||
const str = item.children?.find((c: SyntaxNode) => c.type === 'string');
|
||||
const s = str ? extractStringContent(str) : null;
|
||||
if (s) ctx.middleware.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key === 'prefix') {
|
||||
ctx.prefix = extractStringContent(val) ?? null;
|
||||
} else if (key === 'controller') {
|
||||
if (val?.type === 'class_constant_access_expression') {
|
||||
ctx.controller = val.children?.find((c: SyntaxNode) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function extractLaravelRoutes(tree: Parser.Tree, filePath: string): ExtractedRoute[] {
|
||||
const routes: ExtractedRoute[] = [];
|
||||
|
||||
function resolveStack(stack: RouteGroupContext[]): {
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
controller: string | null;
|
||||
} {
|
||||
const middleware: string[] = [];
|
||||
let prefix: string | null = null;
|
||||
let controller: string | null = null;
|
||||
for (const ctx of stack) {
|
||||
middleware.push(...ctx.middleware);
|
||||
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
|
||||
if (ctx.controller) controller = ctx.controller;
|
||||
}
|
||||
return { middleware, prefix, controller };
|
||||
}
|
||||
|
||||
function emitRoute(
|
||||
httpMethod: string,
|
||||
argsNode: SyntaxNode | null,
|
||||
lineNumber: number,
|
||||
groupStack: RouteGroupContext[],
|
||||
chainAttrs: { method: string; argsNode: SyntaxNode | null }[],
|
||||
) {
|
||||
const effective = resolveStack(groupStack);
|
||||
|
||||
for (const attr of chainAttrs) {
|
||||
if (attr.method === 'middleware')
|
||||
effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') {
|
||||
const p = extractFirstStringArg(attr.argsNode);
|
||||
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
|
||||
}
|
||||
if (attr.method === 'controller') {
|
||||
const cls = extractClassArg(attr.argsNode);
|
||||
if (cls) effective.controller = cls;
|
||||
}
|
||||
}
|
||||
|
||||
const routePath = extractFirstStringArg(argsNode);
|
||||
|
||||
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
|
||||
for (const action of actions) {
|
||||
routes.push({
|
||||
filePath,
|
||||
httpMethod,
|
||||
routePath,
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: action,
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
routes.push({
|
||||
filePath,
|
||||
httpMethod,
|
||||
routePath,
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: target.method,
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative traversal using an explicit stack to avoid V8 call stack overflow
|
||||
// on deeply nested ASTs (e.g. Go stdlib, large Grafana components).
|
||||
// Each frame tracks the node and a snapshot of the group stack at that depth.
|
||||
interface WalkFrame {
|
||||
node: SyntaxNode;
|
||||
groupSnapshot: RouteGroupContext[];
|
||||
}
|
||||
|
||||
const walkStack: WalkFrame[] = [{ node: tree.rootNode, groupSnapshot: [] }];
|
||||
|
||||
while (walkStack.length > 0) {
|
||||
const { node, groupSnapshot } = walkStack.pop()!;
|
||||
|
||||
// Case 1: Simple Route::get(...), Route::post(...), etc.
|
||||
if (isRouteStaticCall(node)) {
|
||||
const method = getCallMethodName(node);
|
||||
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
|
||||
emitRoute(method, getArguments(node), node.startPosition.row, groupSnapshot, []);
|
||||
continue;
|
||||
}
|
||||
if (method === 'group') {
|
||||
const argsNode = getArguments(node);
|
||||
const groupCtx = parseArrayGroupArgs(argsNode);
|
||||
const body = findClosureBody(argsNode);
|
||||
if (body) {
|
||||
const childSnapshot = [...groupSnapshot, groupCtx];
|
||||
const children = body.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot: childSnapshot });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...)
|
||||
const chain = unwrapRouteChain(node);
|
||||
if (chain) {
|
||||
if (chain.terminalMethod === 'group') {
|
||||
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
|
||||
for (const attr of chain.attributes) {
|
||||
if (attr.method === 'middleware')
|
||||
groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
|
||||
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
|
||||
}
|
||||
const body = findClosureBody(chain.terminalArgs);
|
||||
if (body) {
|
||||
const childSnapshot = [...groupSnapshot, groupCtx];
|
||||
const children = body.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot: childSnapshot });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
ROUTE_HTTP_METHODS.has(chain.terminalMethod) ||
|
||||
ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)
|
||||
) {
|
||||
emitRoute(
|
||||
chain.terminalMethod,
|
||||
chain.terminalArgs,
|
||||
node.startPosition.row,
|
||||
groupSnapshot,
|
||||
chain.attributes,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: push children in reverse so leftmost is processed first
|
||||
const children = node.children ?? [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
walkStack.push({ node: children[i], groupSnapshot });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORM Query Detection (Prisma + Supabase)
|
||||
// ============================================================================
|
||||
|
|
|
|||
165
gitnexus/test/unit/blade-template-routes.test.ts
Normal file
165
gitnexus/test/unit/blade-template-routes.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
extractTemplateStaticFetchCalls,
|
||||
isTemplateRouteCandidate,
|
||||
normalizeExtractedRoutePath,
|
||||
routesPhase,
|
||||
} from '../../src/core/ingestion/pipeline-phases/routes.js';
|
||||
import type { ParseOutput } from '../../src/core/ingestion/pipeline-phases/parse.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
||||
describe('Blade/template static route extraction', () => {
|
||||
it('keeps Blade files as template route candidates', () => {
|
||||
expect(isTemplateRouteCandidate('resources/views/orders/index.blade.php')).toBe(true);
|
||||
expect(isTemplateRouteCandidate('resources\\views\\Orders\\INDEX.BLADE.PHP')).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts safe static form, href, AJAX, and Blade URL helper URLs', () => {
|
||||
const calls = extractTemplateStaticFetchCalls(
|
||||
'resources/views/orders/index.blade.php',
|
||||
`<form action="/orders" method="POST">
|
||||
<a href='/orders/history'>History</a>
|
||||
<script>
|
||||
$.ajax({ url: '/api/orders', method: 'POST' });
|
||||
</script>
|
||||
<a href="{{ url('/checkout') }}">Checkout</a>
|
||||
<a href="{!! url('/checkout/raw') !!}">Raw checkout</a>
|
||||
</form>`,
|
||||
);
|
||||
|
||||
expect(new Set(calls.map((call) => call.fetchURL))).toEqual(
|
||||
new Set(['/orders', '/orders/history', '/api/orders', '/checkout', '/checkout/raw']),
|
||||
);
|
||||
expect(new Set(calls.map((call) => call.filePath))).toEqual(
|
||||
new Set(['resources/views/orders/index.blade.php']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat Laravel asset helper URLs as route signals', () => {
|
||||
const calls = extractTemplateStaticFetchCalls(
|
||||
'resources/views/layouts/app.blade.php',
|
||||
`<link href="{{ asset('/css/app.css') }}" rel="stylesheet">
|
||||
<script src="{!! asset('/js/app.js') !!}"></script>`,
|
||||
);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves parameterless Blade named route helpers from extracted route names', () => {
|
||||
const calls = extractTemplateStaticFetchCalls(
|
||||
'resources/views/auth/login.blade.php',
|
||||
`<a href="{{ route('login') }}">Login</a>
|
||||
<form method="POST" action="{!! route('log-viewer.login.submit') !!}"></form>
|
||||
<a href="{{ route('orders.show') }}">Missing parameter</a>
|
||||
<a href="{{ route('orders.show', $order) }}">Dynamic order</a>`,
|
||||
new Map([
|
||||
['login', '/login'],
|
||||
['log-viewer.login.submit', '/logs/login'],
|
||||
['orders.show', '/orders/{order}'],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(calls.map((call) => call.fetchURL)).toEqual(['/login', '/logs/login']);
|
||||
});
|
||||
|
||||
it('does not turn dynamic Blade expressions or parameterized named routes into static URL signals', () => {
|
||||
const calls = extractTemplateStaticFetchCalls(
|
||||
'resources/views/orders/show.blade.php',
|
||||
`<a href="{{ $url }}">Dynamic</a>
|
||||
<a href="{{ route('orders.show', $order) }}">Named route follow-up</a>
|
||||
<a href="{{ url($dynamic) }}">Dynamic helper</a>
|
||||
<script>
|
||||
$.ajax({ url: '/orders/' + id, method: 'GET' });
|
||||
axios({ url: \`/orders/\${id}\`, method: 'GET' });
|
||||
</script>`,
|
||||
);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes Laravel route prefixes before matching template URL signals', () => {
|
||||
expect(normalizeExtractedRoutePath('/orders', 'admin')).toBe('/admin/orders');
|
||||
expect(normalizeExtractedRoutePath('orders', '/admin/')).toBe('/admin/orders');
|
||||
expect(normalizeExtractedRoutePath('/', 'admin')).toBe('/admin');
|
||||
expect(normalizeExtractedRoutePath('/orders', null)).toBe('/orders');
|
||||
});
|
||||
|
||||
it('links Blade static URL signals to matching route graph nodes without PHP parsing', async () => {
|
||||
const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-blade-routes-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(repoPath, 'routes'), { recursive: true });
|
||||
await fs.mkdir(path.join(repoPath, 'resources/views/orders'), { recursive: true });
|
||||
await fs.writeFile(path.join(repoPath, 'routes/web.php'), `<?php\nRoute::post('/orders');\n`);
|
||||
await fs.writeFile(
|
||||
path.join(repoPath, 'resources/views/orders/index.blade.php'),
|
||||
`<form action="/admin/orders" method="POST">
|
||||
<a href="{{ route('admin.orders') }}">Orders</a>
|
||||
<link href="{{ asset('/css/app.css') }}" rel="stylesheet">
|
||||
</form>`,
|
||||
);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const parseOutput = {
|
||||
allPaths: ['routes/web.php', 'resources/views/orders/index.blade.php'],
|
||||
allFetchCalls: [],
|
||||
allExtractedRoutes: [
|
||||
{
|
||||
filePath: 'routes/web.php',
|
||||
httpMethod: 'post',
|
||||
routePath: '/orders',
|
||||
routeName: 'admin.orders',
|
||||
controllerName: null,
|
||||
methodName: null,
|
||||
middleware: [],
|
||||
prefix: 'admin',
|
||||
lineNumber: 1,
|
||||
},
|
||||
{
|
||||
filePath: 'routes/web.php',
|
||||
httpMethod: 'get',
|
||||
routePath: '/css/app.css',
|
||||
routeName: 'assets.css',
|
||||
controllerName: null,
|
||||
methodName: null,
|
||||
middleware: [],
|
||||
prefix: null,
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
allDecoratorRoutes: [],
|
||||
} as unknown as ParseOutput;
|
||||
|
||||
const output = await routesPhase.execute(
|
||||
{
|
||||
repoPath,
|
||||
graph,
|
||||
onProgress: () => {},
|
||||
pipelineStart: Date.now(),
|
||||
},
|
||||
new Map([['parse', { phaseName: 'parse', output: parseOutput, durationMs: 0 }]]),
|
||||
);
|
||||
|
||||
expect(output.routeRegistry.get('/admin/orders')).toEqual({
|
||||
filePath: 'routes/web.php',
|
||||
source: 'framework-route',
|
||||
});
|
||||
|
||||
const fetchEdges = graph.relationships.filter((rel) => rel.type === 'FETCHES');
|
||||
expect(fetchEdges).toHaveLength(1);
|
||||
expect(fetchEdges.map((rel) => graph.getNode(rel.targetId)?.properties.name)).toEqual([
|
||||
'/admin/orders',
|
||||
]);
|
||||
const target = graph.getNode(fetchEdges[0]!.targetId);
|
||||
expect(fetchEdges[0]!.sourceId).toBe(
|
||||
generateId('File', 'resources/views/orders/index.blade.php'),
|
||||
);
|
||||
expect(target?.properties.name).toBe('/admin/orders');
|
||||
} finally {
|
||||
await fs.rm(repoPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => {
|
|||
});
|
||||
|
||||
import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
|
||||
import { getPluginForFile } from '../../../src/core/group/extractors/http-patterns/index.js';
|
||||
import type { RepoHandle } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('HttpRouteExtractor', () => {
|
||||
|
|
@ -33,6 +34,13 @@ describe('HttpRouteExtractor', () => {
|
|||
storagePath: path.join(repoPath, '.gitnexus'),
|
||||
});
|
||||
|
||||
describe('plugin selection', () => {
|
||||
it('does not route Blade templates through the PHP source-scan plugin', () => {
|
||||
expect(getPluginForFile('resources/views/welcome.blade.php')).toBeUndefined();
|
||||
expect(getPluginForFile('routes/web.php')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider extraction — graph-first (Strategy A)', () => {
|
||||
it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
|
||||
const dir = path.join(tmpDir, 'graph-first');
|
||||
|
|
|
|||
|
|
@ -178,6 +178,12 @@ describe('shouldIgnorePath', () => {
|
|||
it('ignores TypeScript declaration files', () => {
|
||||
expect(shouldIgnorePath('types/index.d.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Laravel compiled Blade view cache files', () => {
|
||||
expect(shouldIgnorePath('storage/framework/views/1a2b3c.php')).toBe(true);
|
||||
expect(shouldIgnorePath('project/storage/framework/views/1a2b3c.php')).toBe(true);
|
||||
expect(shouldIgnorePath('project\\storage\\framework\\views\\1a2b3c.php')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows path normalization', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getProvider } from '../../src/core/ingestion/languages/index.js';
|
||||
import {
|
||||
getLanguageFromFilename,
|
||||
getSyntaxLanguageFromFilename,
|
||||
isBladeTemplateFilename,
|
||||
SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
import { getProvider, getProviderForFile } from '../../src/core/ingestion/languages/index.js';
|
||||
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from '../../src/core/ingestion/language-provider.js';
|
||||
|
|
@ -87,6 +92,24 @@ describe('getLanguageFromFilename', () => {
|
|||
it.each(['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'])('detects %s files', (ext) => {
|
||||
expect(getLanguageFromFilename(`file${ext}`)).toBe(SupportedLanguages.PHP);
|
||||
});
|
||||
|
||||
it('treats Laravel Blade templates as markup templates, not PHP code files', () => {
|
||||
expect(getLanguageFromFilename('resources/views/users/index.blade.php')).toBeNull();
|
||||
expect(getSyntaxLanguageFromFilename('resources/views/users/index.blade.php')).toBe('markup');
|
||||
expect(isBladeTemplateFilename('resources/views/users/index.blade.php')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises Blade templates with Windows paths and case variants', () => {
|
||||
const file = 'resources\\views\\Users\\INDEX.BLADE.PHP';
|
||||
expect(isBladeTemplateFilename(file)).toBe(true);
|
||||
expect(getLanguageFromFilename(file)).toBeNull();
|
||||
expect(getSyntaxLanguageFromFilename(file)).toBe('markup');
|
||||
});
|
||||
|
||||
it('keeps .phtml classified as provider-backed PHP', () => {
|
||||
expect(getLanguageFromFilename('templates/product/list.phtml')).toBe(SupportedLanguages.PHP);
|
||||
expect(getSyntaxLanguageFromFilename('templates/product/list.phtml')).toBe('php');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Swift', () => {
|
||||
|
|
@ -133,6 +156,21 @@ describe('getLanguageFromFilename', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getProviderForFile', () => {
|
||||
it('does not route Blade templates to the PHP provider', () => {
|
||||
expect(getProviderForFile('resources/views/users/index.blade.php')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps PHP and PHTML files on the PHP provider', () => {
|
||||
expect(getProviderForFile('app/Http/Controllers/UserController.php')?.id).toBe(
|
||||
SupportedLanguages.PHP,
|
||||
);
|
||||
expect(getProviderForFile('vendor/mage-os/templates/product/list.phtml')?.id).toBe(
|
||||
SupportedLanguages.PHP,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBuiltInOrNoise', () => {
|
||||
const js = getProvider(SupportedLanguages.JavaScript);
|
||||
const py = getProvider(SupportedLanguages.Python);
|
||||
|
|
|
|||
217
gitnexus/test/unit/laravel-route-extraction.test.ts
Normal file
217
gitnexus/test/unit/laravel-route-extraction.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import { extractLaravelRoutes } from '../../src/core/ingestion/route-extractors/laravel.js';
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(PHP.php_only);
|
||||
|
||||
const extract = (source: string) =>
|
||||
extractLaravelRoutes(parser.parse(source), 'routes/web.php').map((route) => ({
|
||||
httpMethod: route.httpMethod,
|
||||
routePath: route.routePath,
|
||||
controllerName: route.controllerName,
|
||||
methodName: route.methodName,
|
||||
routeName: route.routeName,
|
||||
middleware: route.middleware,
|
||||
prefix: route.prefix,
|
||||
}));
|
||||
|
||||
describe('Laravel route extraction', () => {
|
||||
it('extracts representative HTTP verb route declarations', () => {
|
||||
const routes = extract(`<?php
|
||||
Route::get('/orders', [OrderController::class, 'index']);
|
||||
Route::post('/orders', [OrderController::class, 'store']);
|
||||
Route::put('/orders/{order}', [OrderController::class, 'update']);
|
||||
Route::patch('/orders/{order}', [OrderController::class, 'patch']);
|
||||
Route::delete('/orders/{order}', [OrderController::class, 'destroy']);
|
||||
Route::options('/orders/options', [OrderController::class, 'options']);
|
||||
Route::any('/orders/any', [OrderController::class, 'any']);
|
||||
Route::match(['get', 'post'], '/orders/search', [OrderController::class, 'search']);
|
||||
`);
|
||||
|
||||
expect(routes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ httpMethod: 'get', routePath: '/orders', methodName: 'index' }),
|
||||
expect.objectContaining({ httpMethod: 'post', routePath: '/orders', methodName: 'store' }),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'put',
|
||||
routePath: '/orders/{order}',
|
||||
methodName: 'update',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'patch',
|
||||
routePath: '/orders/{order}',
|
||||
methodName: 'patch',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'delete',
|
||||
routePath: '/orders/{order}',
|
||||
methodName: 'destroy',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'options',
|
||||
routePath: '/orders/options',
|
||||
methodName: 'options',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'any',
|
||||
routePath: '/orders/any',
|
||||
methodName: 'any',
|
||||
}),
|
||||
expect.objectContaining({ httpMethod: 'match', routePath: '/orders/search' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('expands resource and apiResource controller actions', () => {
|
||||
const routes = extract(`<?php
|
||||
Route::resource('/photos', PhotoController::class);
|
||||
Route::apiResource('/api/photos', ApiPhotoController::class);
|
||||
`);
|
||||
|
||||
const photos = routes.filter((route) => route.routePath === '/photos');
|
||||
expect(photos.map((route) => route.methodName)).toEqual([
|
||||
'index',
|
||||
'create',
|
||||
'store',
|
||||
'show',
|
||||
'edit',
|
||||
'update',
|
||||
'destroy',
|
||||
]);
|
||||
expect(new Set(photos.map((route) => route.controllerName))).toEqual(
|
||||
new Set(['PhotoController']),
|
||||
);
|
||||
expect(photos.map((route) => route.routeName)).toEqual([
|
||||
'photos.index',
|
||||
'photos.create',
|
||||
'photos.store',
|
||||
'photos.show',
|
||||
'photos.edit',
|
||||
'photos.update',
|
||||
'photos.destroy',
|
||||
]);
|
||||
|
||||
const apiPhotos = routes.filter((route) => route.routePath === '/api/photos');
|
||||
expect(apiPhotos.map((route) => route.methodName)).toEqual([
|
||||
'index',
|
||||
'store',
|
||||
'show',
|
||||
'update',
|
||||
'destroy',
|
||||
]);
|
||||
expect(new Set(apiPhotos.map((route) => route.controllerName))).toEqual(
|
||||
new Set(['ApiPhotoController']),
|
||||
);
|
||||
expect(apiPhotos.map((route) => route.routeName)).toEqual([
|
||||
'api.photos.index',
|
||||
'api.photos.store',
|
||||
'api.photos.show',
|
||||
'api.photos.update',
|
||||
'api.photos.destroy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('threads middleware, prefix, and controller chains into grouped routes', () => {
|
||||
const routes = extract(`<?php
|
||||
Route::get('/loose', 'index');
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'api',
|
||||
'middleware' => ['auth'],
|
||||
'controller' => ApiOrderController::class,
|
||||
], function () {
|
||||
Route::post('/orders', 'store');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])
|
||||
->prefix('admin')
|
||||
->controller(OrderController::class)
|
||||
->group(function () {
|
||||
Route::get('/orders', 'index');
|
||||
});
|
||||
`);
|
||||
|
||||
expect(routes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'get',
|
||||
routePath: '/orders',
|
||||
controllerName: 'OrderController',
|
||||
methodName: 'index',
|
||||
middleware: ['auth', 'verified'],
|
||||
prefix: 'admin',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'post',
|
||||
routePath: '/orders',
|
||||
controllerName: 'ApiOrderController',
|
||||
methodName: 'store',
|
||||
middleware: ['auth'],
|
||||
prefix: 'api',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'get',
|
||||
routePath: '/loose',
|
||||
controllerName: null,
|
||||
methodName: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts named routes from fluent routes and named groups', () => {
|
||||
const routes = extract(`<?php
|
||||
Route::get('/login', [AuthController::class, 'login'])->name('login');
|
||||
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth')->name('logout');
|
||||
|
||||
Route::name('admin.')
|
||||
->prefix('admin')
|
||||
->group(function () {
|
||||
Route::post('/settings/cache', [SettingsController::class, 'refresh'])
|
||||
->name('settings.refresh-cache');
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'as' => 'log-viewer.',
|
||||
'prefix' => 'logs',
|
||||
], function () {
|
||||
Route::post('/login', [LogViewerController::class, 'login'])
|
||||
->name('login.submit');
|
||||
});
|
||||
`);
|
||||
|
||||
expect(routes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
httpMethod: 'get',
|
||||
routePath: '/login',
|
||||
routeName: 'login',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'post',
|
||||
routePath: '/logout',
|
||||
routeName: 'logout',
|
||||
middleware: ['auth'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'post',
|
||||
routePath: '/settings/cache',
|
||||
routeName: 'admin.settings.refresh-cache',
|
||||
prefix: 'admin',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
httpMethod: 'post',
|
||||
routePath: '/login',
|
||||
routeName: 'log-viewer.login.submit',
|
||||
prefix: 'logs',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
72
gitnexus/test/unit/php-template-scope.test.ts
Normal file
72
gitnexus/test/unit/php-template-scope.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getProviderForFile } from '../../src/core/ingestion/languages/index.js';
|
||||
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
|
||||
const parseWithWarnings = (filePath: string, source: string) => {
|
||||
const provider = getProviderForFile(filePath);
|
||||
expect(provider?.id).toBe(SupportedLanguages.PHP);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const parsed = extractParsedFile(provider!, source, filePath, (message) => {
|
||||
warnings.push(message);
|
||||
});
|
||||
|
||||
return { parsed, warnings };
|
||||
};
|
||||
|
||||
describe('PHP template scope extraction', () => {
|
||||
it('keeps Magento/Mage-OS PHTML templates on PHP scope extraction without module warnings', () => {
|
||||
const filePath = 'vendor/mage-os/module-catalog/view/frontend/templates/product/list.phtml';
|
||||
const source = `<div><?= $block->getChildHtml() ?></div>
|
||||
<?php echo $escaper->escapeHtml($title); ?>`;
|
||||
|
||||
expect(getLanguageFromFilename(filePath)).toBe(SupportedLanguages.PHP);
|
||||
|
||||
const { parsed, warnings } = parseWithWarnings(filePath, source);
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(parsed?.scopes[0]?.kind).toBe('Module');
|
||||
expect(parsed?.referenceSites).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'getChildHtml',
|
||||
kind: 'call',
|
||||
callForm: 'member',
|
||||
explicitReceiver: { name: '$block' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'escapeHtml',
|
||||
kind: 'call',
|
||||
callForm: 'member',
|
||||
explicitReceiver: { name: '$escaper' },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps namespace-less PHP config files module-scoped without module warnings', () => {
|
||||
const filePath = 'config/database.php';
|
||||
const source = `<?php
|
||||
return [
|
||||
'driver' => env('DB_CONNECTION', 'mysql'),
|
||||
];`;
|
||||
|
||||
expect(getLanguageFromFilename(filePath)).toBe(SupportedLanguages.PHP);
|
||||
|
||||
const { parsed, warnings } = parseWithWarnings(filePath, source);
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(parsed?.scopes[0]?.kind).toBe('Module');
|
||||
expect(parsed?.referenceSites).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'env',
|
||||
kind: 'call',
|
||||
callForm: 'free',
|
||||
arity: 2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue