Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
Gergő Magyar 2026-05-23 23:38:35 +01:00 • committed by GitHub
commit ed176527e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1133 additions and 457 deletions

View file

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

View file

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

View file

@ -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)) {

View file

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

View file

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

View file

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

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

View file

@ -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)
// ============================================================================

View 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 });
}
});
});

View file

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

View file

@ -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', () => {

View file

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

View 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',
}),
]),
);
});
});

View 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,
}),
]),
);
});
});