From 2b6e7ffbd93b5f9922a6a28d5e978f6748fcfaa3 Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Sun, 24 May 2026 06:37:40 +0800 Subject: [PATCH] fix(php): avoid Blade templates entering PHP analysis (#1790) --- gitnexus-shared/src/index.ts | 6 +- gitnexus-shared/src/language-detection.ts | 12 + gitnexus/src/config/ignore-service.ts | 10 + .../group/extractors/http-patterns/index.ts | 2 + .../src/core/ingestion/languages/index.ts | 4 +- .../core/ingestion/pipeline-phases/routes.ts | 117 ++++- .../ingestion/route-extractors/laravel.ts | 495 ++++++++++++++++++ .../core/ingestion/workers/parse-worker.ts | 434 +-------------- .../test/unit/blade-template-routes.test.ts | 165 ++++++ .../unit/group/http-route-extractor.test.ts | 8 + gitnexus/test/unit/ignore-service.test.ts | 6 + gitnexus/test/unit/ingestion-utils.test.ts | 42 +- .../unit/laravel-route-extraction.test.ts | 217 ++++++++ gitnexus/test/unit/php-template-scope.test.ts | 72 +++ 14 files changed, 1133 insertions(+), 457 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/laravel.ts create mode 100644 gitnexus/test/unit/blade-template-routes.test.ts create mode 100644 gitnexus/test/unit/laravel-route-extraction.test.ts create mode 100644 gitnexus/test/unit/php-template-scope.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index e2d5df284..fc591fdb0 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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 diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index d31f9d58d..f9073b8c0 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -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 = { * 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(); diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 2c3eebe3b..17b9d14bd 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -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)) { diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index e33d32a79..c62eb618c 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -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]; } diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index 041af775d..6191e881b 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index de3a8ddb8..a1ee73eea 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -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; } +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 = new Map(), +): TemplateFetchCall[] { + const calls: TemplateFetchCall[] = []; + const seen = new Set(); + + 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 = { name: 'routes', deps: ['parse'], @@ -111,6 +195,7 @@ export const routesPhase: PipelinePhase = { const ensureSlash = (path: string) => (path.startsWith('/') ? path : '/' + path); let duplicateRoutes = 0; + const namedRouteRegistry = new Map(); const addRoute = (url: string, entry: RouteEntry) => { if (routeRegistry.has(url)) { duplicateRoutes++; @@ -120,10 +205,14 @@ export const routesPhase: PipelinePhase = { }; 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 = { } } - // 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), + ); } } diff --git a/gitnexus/src/core/ingestion/route-extractors/laravel.ts b/gitnexus/src/core/ingestion/route-extractors/laravel.ts new file mode 100644 index 000000000..6d934bf5b --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/laravel.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ccb88e0de..69e28b91f 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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) // ============================================================================ diff --git a/gitnexus/test/unit/blade-template-routes.test.ts b/gitnexus/test/unit/blade-template-routes.test.ts new file mode 100644 index 000000000..9edd6e791 --- /dev/null +++ b/gitnexus/test/unit/blade-template-routes.test.ts @@ -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', + `
+ History + + Checkout + Raw checkout +
`, + ); + + 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', + ` +`, + ); + + expect(calls).toEqual([]); + }); + + it('resolves parameterless Blade named route helpers from extracted route names', () => { + const calls = extractTemplateStaticFetchCalls( + 'resources/views/auth/login.blade.php', + `Login +
+Missing parameter +Dynamic order`, + 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', + `Dynamic +Named route follow-up +Dynamic helper +`, + ); + + 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'), ` + Orders + +`, + ); + + 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 }); + } + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index a1756be3b..000403bce 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -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'); diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 1e1908137..cd5ebdb4e 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/ingestion-utils.test.ts b/gitnexus/test/unit/ingestion-utils.test.ts index c75006a96..c11e716eb 100644 --- a/gitnexus/test/unit/ingestion-utils.test.ts +++ b/gitnexus/test/unit/ingestion-utils.test.ts @@ -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); diff --git a/gitnexus/test/unit/laravel-route-extraction.test.ts b/gitnexus/test/unit/laravel-route-extraction.test.ts new file mode 100644 index 000000000..5f77eaa69 --- /dev/null +++ b/gitnexus/test/unit/laravel-route-extraction.test.ts @@ -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(` { + const routes = extract(` 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(` '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(`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', + }), + ]), + ); + }); +}); diff --git a/gitnexus/test/unit/php-template-scope.test.ts b/gitnexus/test/unit/php-template-scope.test.ts new file mode 100644 index 000000000..937dda1b3 --- /dev/null +++ b/gitnexus/test/unit/php-template-scope.test.ts @@ -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 = `
getChildHtml() ?>
+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 = ` 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, + }), + ]), + ); + }); +});