fix: resolve constant-backed Spring route paths

Finalize Spring route candidates after import resolution so Java Spring mappings can use local, imported, and fully qualified constants. Skip unresolved explicit mappings and expand route metadata coverage to prevent bad fallback routes.

Made-with: Cursor
This commit is contained in:
许恩宁 2026-04-02 18:55:35 +08:00
parent d8df203cf7
commit 71735cf9b6
17 changed files with 529 additions and 126 deletions

View file

@ -4,6 +4,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import type { FieldExtractionConfig } from '../generic.js';
import { findVisibility, hasKeyword, hasModifier, typeFromField } from './helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import { extractJavaStringLiteral } from '../../utils/java-strings.js';
import type { FieldVisibility } from '../../field-types.js';
// ---------------------------------------------------------------------------
@ -65,6 +66,17 @@ export const javaConfig: FieldExtractionConfig = {
isReadonly(node) {
return hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final');
},
extractConstantValue(node) {
const isStatic = hasKeyword(node, 'static') || hasModifier(node, 'modifiers', 'static');
const isReadonly = hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final');
if (!isStatic || !isReadonly) return undefined;
const declarator =
node.childForFieldName('declarator') ??
node.namedChildren.find((child) => child.type === 'variable_declarator');
const valueNode = declarator?.childForFieldName('value');
return extractJavaStringLiteral(valueNode);
},
};
// ---------------------------------------------------------------------------

View file

@ -53,6 +53,8 @@ export interface FieldExtractionConfig {
isStatic: (node: SyntaxNode) => boolean;
/** Check if a field is readonly/final/const */
isReadonly: (node: SyntaxNode) => boolean;
/** Extract compile-time constant string value when available */
extractConstantValue?: (node: SyntaxNode) => string | undefined;
/** Extract fields from primary constructor parameters on the owner node itself
* (e.g. C# record positional parameters, C# 12 class primary constructors). */
extractPrimaryFields?: (ownerNode: SyntaxNode, context: FieldExtractorContext) => FieldInfo[];
@ -176,12 +178,15 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
if (resolved) type = resolved;
}
const constantValue = config.extractConstantValue?.(node);
return {
name,
type,
visibility: config.extractVisibility(node),
isStatic: config.isStatic(node),
isReadonly: config.isReadonly(node),
...(constantValue !== undefined ? { constantValue } : {}),
sourceFile: context.filePath,
line: node.startPosition.row + 1,
};

View file

@ -39,6 +39,8 @@ export interface FieldInfo {
isStatic: boolean;
/** Is this readonly/const? */
isReadonly: boolean;
/** Compile-time constant string value when available */
constantValue?: string;
/** Source file path */
sourceFile: string;
/** Line number */

View file

@ -9,6 +9,7 @@
* so adding a language to the enum without creating a provider is a compiler error.
*/
import type Parser from 'tree-sitter';
import type { SupportedLanguages } from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
@ -19,6 +20,7 @@ import type { ImportResolverFn } from './import-resolvers/types.js';
import type { NamedBindingExtractorFn } from './named-bindings/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type { ExtractedDeferredRouteCandidate } from './route-extractors/spring-java-types.js';
// ── Shared type aliases ────────────────────────────────────────────────────
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
@ -143,6 +145,12 @@ interface LanguageProviderConfig {
* When true, the worker extracts routes via the language's route extraction logic.
* Default: undefined (no route files). */
readonly isRouteFile?: (filePath: string) => boolean;
/** Extract deferred framework route candidates that need finalize after imports/symbols.
* Default: undefined (no deferred route extraction). */
readonly deferredRouteExtractor?: (
tree: Parser.Tree,
filePath: string,
) => ExtractedDeferredRouteCandidate[];
// ── Noise filtering ────────────────────────────────────────────────
/** Built-in/stdlib names that should be filtered from the call graph for this language.

View file

@ -17,6 +17,7 @@ import { JAVA_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { javaConfig } from '../field-extractors/configs/jvm.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { extractSpringJavaRouteCandidates } from '../route-extractors/spring-java.js';
import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
function isSpringRouteFile(filePath: string): boolean {
@ -41,4 +42,5 @@ export const javaProvider = defineLanguage({
fieldExtractor: createFieldExtractor(javaConfig),
methodExtractor: createMethodExtractor(javaMethodConfig),
isRouteFile: isSpringRouteFile,
deferredRouteExtractor: extractSpringJavaRouteCandidates,
});

View file

@ -6,7 +6,7 @@ import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
import { yieldToEventLoop } from './utils/event-loop.js';
import {
getDefinitionNodeFromCaptures,
@ -18,9 +18,11 @@ import {
} from './utils/ast-helpers.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { buildTypeEnv } from './type-env.js';
import type { ExtractedDeferredRouteCandidate } from './route-extractors/spring-java-types.js';
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
import type { LanguageProvider } from './language-provider.js';
import { WorkerPool } from './workers/worker-pool.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
import type {
ParseWorkerResult,
ParseWorkerInput,
@ -36,7 +38,6 @@ import type {
FileTypeEnvBindings,
ExtractedORMQuery,
} from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@ -48,12 +49,30 @@ export interface WorkerExtractedData {
routes: ExtractedRoute[];
fetchCalls: ExtractedFetchCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
deferredRouteCandidates: ExtractedDeferredRouteCandidate[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
constructorBindings: FileConstructorBindings[];
typeEnvBindings: FileTypeEnvBindings[];
}
function createEmptyExtractedData(): WorkerExtractedData {
return {
imports: [],
calls: [],
assignments: [],
heritage: [],
routes: [],
fetchCalls: [],
decoratorRoutes: [],
deferredRouteCandidates: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
typeEnvBindings: [],
};
}
// ============================================================================
// Worker-based parallel parsing
// ============================================================================
@ -73,20 +92,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0)
return {
imports: [],
calls: [],
assignments: [],
heritage: [],
routes: [],
fetchCalls: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
typeEnvBindings: [],
};
if (parseableFiles.length === 0) return createEmptyExtractedData();
const total = files.length;
@ -106,6 +112,7 @@ const processParsingWithWorkers = async (
const allRoutes: ExtractedRoute[] = [];
const allFetchCalls: ExtractedFetchCall[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allDeferredRouteCandidates: ExtractedDeferredRouteCandidate[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
@ -130,6 +137,7 @@ const processParsingWithWorkers = async (
parameterTypes: sym.parameterTypes,
returnType: sym.returnType,
declaredType: sym.declaredType,
constantValue: sym.constantValue,
ownerId: sym.ownerId,
});
}
@ -141,6 +149,7 @@ const processParsingWithWorkers = async (
allRoutes.push(...result.routes);
allFetchCalls.push(...result.fetchCalls);
allDecoratorRoutes.push(...result.decoratorRoutes);
allDeferredRouteCandidates.push(...result.deferredRouteCandidates);
allToolDefs.push(...result.toolDefs);
if (result.ormQueries) allORMQueries.push(...result.ormQueries);
allConstructorBindings.push(...result.constructorBindings);
@ -171,6 +180,7 @@ const processParsingWithWorkers = async (
routes: allRoutes,
fetchCalls: allFetchCalls,
decoratorRoutes: allDecoratorRoutes,
deferredRouteCandidates: allDeferredRouteCandidates,
toolDefs: allToolDefs,
ormQueries: allORMQueries,
constructorBindings: allConstructorBindings,
@ -250,8 +260,9 @@ const processParsingSequential = async (
symbolTable: SymbolTable,
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
) => {
): Promise<WorkerExtractedData> => {
const parser = await loadParser();
const extractedData = createEmptyExtractedData();
const total = files.length;
const skippedLanguages = new Map<string, number>();
@ -421,6 +432,7 @@ const processParsingSequential = async (
let seqVisibility: string | undefined;
let seqIsStatic: boolean | undefined;
let seqIsReadonly: boolean | undefined;
let seqConstantValue: string | undefined;
if (nodeLabel === 'Property' && definitionNode) {
// FieldExtractor is the single source of truth when available
if (provider.fieldExtractor && typeEnv) {
@ -438,6 +450,7 @@ const processParsingSequential = async (
seqVisibility = info.visibility;
seqIsStatic = info.isStatic;
seqIsReadonly = info.isReadonly;
seqConstantValue = info.constantValue;
}
}
}
@ -448,6 +461,7 @@ const processParsingSequential = async (
if (seqVisibility !== undefined) node.properties.visibility = seqVisibility;
if (seqIsStatic !== undefined) node.properties.isStatic = seqIsStatic;
if (seqIsReadonly !== undefined) node.properties.isReadonly = seqIsReadonly;
if (seqConstantValue !== undefined) node.properties.constantValue = seqConstantValue;
if (declaredType !== undefined) node.properties.declaredType = declaredType;
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
@ -456,6 +470,7 @@ const processParsingSequential = async (
parameterTypes: methodSig?.parameterTypes,
returnType: methodSig?.returnType,
declaredType,
constantValue: seqConstantValue,
ownerId: enclosingClassId ?? undefined,
});
@ -487,6 +502,10 @@ const processParsingSequential = async (
});
}
});
if (provider.isRouteFile?.(file.path) && provider.deferredRouteExtractor) {
extractedData.deferredRouteCandidates.push(...(provider.deferredRouteExtractor(tree, file.path)));
}
}
if (skippedLanguages.size > 0) {
@ -495,12 +514,19 @@ const processParsingSequential = async (
.join(', ');
console.warn(` Skipped unsupported languages: ${summary}`);
}
return extractedData;
};
// ============================================================================
// Public API
// ============================================================================
export interface ParsingResult {
data: WorkerExtractedData;
usedWorkers: boolean;
}
export const processParsing = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
@ -508,10 +534,10 @@ export const processParsing = async (
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,
): Promise<WorkerExtractedData | null> => {
): Promise<ParsingResult> => {
if (workerPool) {
try {
return await processParsingWithWorkers(
const data = await processParsingWithWorkers(
graph,
files,
symbolTable,
@ -519,6 +545,10 @@ export const processParsing = async (
workerPool,
onFileProgress,
);
return {
data,
usedWorkers: true,
};
} catch (err) {
console.warn(
'Worker pool parsing failed, falling back to sequential:',
@ -527,7 +557,15 @@ export const processParsing = async (
}
}
// Fallback: sequential parsing (no pre-extracted data)
await processParsingSequential(graph, files, symbolTable, astCache, onFileProgress);
return null;
const data = await processParsingSequential(
graph,
files,
symbolTable,
astCache,
onFileProgress,
);
return {
data,
usedWorkers: false,
};
};

View file

@ -25,9 +25,10 @@ import {
mergeImplementorMaps,
} from './call-processor.js';
import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js';
import { finalizeSpringJavaRoutes } from './route-extractors/spring-java.js';
import type { ExtractedDeferredRouteCandidate } from './route-extractors/spring-java-types.js';
import { expoFileToRouteURL } from './route-extractors/expo.js';
import { phpFileToRouteURL } from './route-extractors/php.js';
import { extractSpringJavaRoutes } from './route-extractors/spring-java.js';
import {
extractResponseShapes,
extractPHPResponseShapes,
@ -785,6 +786,7 @@ async function runChunkedParseAndResolve(
const allExtractedRoutes: ExtractedRoute[] = [];
// Accumulate decorator-based routes (@Get, @Post, @app.route, etc.)
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allDeferredRouteCandidates: ExtractedDeferredRouteCandidate[] = [];
// Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.)
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
@ -804,7 +806,7 @@ async function runChunkedParseAndResolve(
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
// Parse this chunk (workers or sequential fallback)
const chunkWorkerData = await processParsing(
const { data: chunkParseData, usedWorkers } = await processParsing(
graph,
chunkFiles,
symbolTable,
@ -828,13 +830,16 @@ async function runChunkedParseAndResolve(
);
const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62;
if (chunkParseData.deferredRouteCandidates.length > 0) {
allDeferredRouteCandidates.push(...chunkParseData.deferredRouteCandidates);
}
if (chunkWorkerData) {
if (usedWorkers) {
// Imports
await processImportsFromExtracted(
graph,
allPathObjects,
chunkWorkerData.imports,
chunkParseData.imports,
ctx,
(current, total) => {
onProgress({
@ -864,7 +869,7 @@ async function runChunkedParseAndResolve(
// it activates only if incremental export collection is added per-chunk.
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
const { enrichedCount } = seedCrossFileReceiverTypes(
chunkWorkerData.calls,
chunkParseData.calls,
ctx.namedImportMap,
exportedTypeMap,
);
@ -874,17 +879,17 @@ async function runChunkedParseAndResolve(
);
}
}
deferredWorkerCalls.push(...chunkWorkerData.calls);
deferredWorkerHeritage.push(...chunkWorkerData.heritage);
deferredConstructorBindings.push(...chunkWorkerData.constructorBindings);
if (chunkWorkerData.assignments?.length) {
deferredAssignments.push(...chunkWorkerData.assignments);
deferredWorkerCalls.push(...chunkParseData.calls);
deferredWorkerHeritage.push(...chunkParseData.heritage);
deferredConstructorBindings.push(...chunkParseData.constructorBindings);
if (chunkParseData.assignments?.length) {
deferredAssignments.push(...chunkParseData.assignments);
}
// Heritage + Routes — calls deferred until all chunks have contributed heritage
// (complete implementor map for interface dispatch).
await Promise.all([
processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => {
processHeritageFromExtracted(graph, chunkParseData.heritage, ctx, (current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
@ -897,7 +902,7 @@ async function runChunkedParseAndResolve(
},
});
}),
processRoutesFromExtracted(graph, chunkWorkerData.routes ?? [], ctx, (current, total) => {
processRoutesFromExtracted(graph, chunkParseData.routes ?? [], ctx, (current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
@ -912,24 +917,24 @@ async function runChunkedParseAndResolve(
}),
]);
// Collect TypeEnv file-scope bindings for exported type enrichment
if (chunkWorkerData.typeEnvBindings?.length) {
workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings);
if (chunkParseData.typeEnvBindings?.length) {
workerTypeEnvBindings.push(...chunkParseData.typeEnvBindings);
}
// Collect fetch() calls for Next.js route matching
if (chunkWorkerData.fetchCalls?.length) {
allFetchCalls.push(...chunkWorkerData.fetchCalls);
if (chunkParseData.fetchCalls?.length) {
allFetchCalls.push(...chunkParseData.fetchCalls);
}
if (chunkWorkerData.routes?.length) {
allExtractedRoutes.push(...chunkWorkerData.routes);
if (chunkParseData.routes?.length) {
allExtractedRoutes.push(...chunkParseData.routes);
}
if (chunkWorkerData.decoratorRoutes?.length) {
allDecoratorRoutes.push(...chunkWorkerData.decoratorRoutes);
if (chunkParseData.decoratorRoutes?.length) {
allDecoratorRoutes.push(...chunkParseData.decoratorRoutes);
}
if (chunkWorkerData.toolDefs?.length) {
allToolDefs.push(...chunkWorkerData.toolDefs);
if (chunkParseData.toolDefs?.length) {
allToolDefs.push(...chunkParseData.toolDefs);
}
if (chunkWorkerData.ormQueries?.length) {
allORMQueries.push(...chunkWorkerData.ormQueries);
if (chunkParseData.ormQueries?.length) {
allORMQueries.push(...chunkParseData.ormQueries);
}
} else {
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
@ -1015,19 +1020,6 @@ async function runChunkedParseAndResolve(
if (rubyHeritage.length > 0) {
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
}
const chunkSpringRoutes: ExtractedRoute[] = [];
for (const file of chunkFiles) {
const provider = getProviderForFile(file.path);
const language = getLanguageFromFilename(file.path);
if (language !== SupportedLanguages.Java || !provider?.isRouteFile?.(file.path)) continue;
const tree = astCache.get(file.path);
if (!tree) continue;
chunkSpringRoutes.push(...extractSpringJavaRoutes(tree, file.path));
}
if (chunkSpringRoutes.length > 0) {
await processRoutesFromExtracted(graph, chunkSpringRoutes, ctx);
allExtractedRoutes.push(...chunkSpringRoutes);
}
// Extract fetch() calls for Next.js route matching (sequential path)
const chunkFetchCalls = await extractFetchCallsFromFiles(chunkFiles, astCache);
if (chunkFetchCalls.length > 0) {
@ -1040,6 +1032,12 @@ async function runChunkedParseAndResolve(
astCache.clear();
}
const finalizedSpringRoutes = finalizeSpringJavaRoutes(allDeferredRouteCandidates, ctx);
if (finalizedSpringRoutes.length > 0) {
await processRoutesFromExtracted(graph, finalizedSpringRoutes, ctx);
allExtractedRoutes.push(...finalizedSpringRoutes);
}
// Log resolution cache stats
if (isDev) {
const rcStats = ctx.getStats();
@ -1369,7 +1367,14 @@ export const runPipelineFromRepo = async (
);
// ── Phase 3.5: Route Registry (Next.js + PHP + Laravel + decorators) ──
type RouteEntry = { filePath: string; source: string };
type RouteEntry = {
filePath: string;
source: string;
httpMethod?: string;
controllerName?: string | null;
methodName?: string | null;
prefix?: string | null;
};
const routeRegistry = new Map<string, RouteEntry>();
// Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe).
@ -1428,12 +1433,17 @@ export const runPipelineFromRepo = async (
addRoute(ensureSlash(route.routePath), {
filePath: route.filePath,
source: 'framework-route',
httpMethod: route.httpMethod,
controllerName: route.controllerName,
methodName: route.methodName,
prefix: route.prefix,
});
}
for (const dr of allDecoratorRoutes) {
addRoute(ensureSlash(dr.routePath), {
filePath: dr.filePath,
source: `decorator-${dr.decoratorName}`,
httpMethod: dr.httpMethod,
});
}
@ -1443,7 +1453,14 @@ export const runPipelineFromRepo = async (
handlerContents = await readFileContents(repoPath, handlerPaths);
for (const [routeURL, entry] of routeRegistry) {
const { filePath: handlerPath, source: routeSource } = entry;
const {
filePath: handlerPath,
source: routeSource,
httpMethod,
controllerName,
methodName,
prefix,
} = entry;
const content = handlerContents.get(handlerPath);
const { responseKeys, errorKeys } = content
@ -1462,6 +1479,10 @@ export const runPipelineFromRepo = async (
properties: {
name: routeURL,
filePath: handlerPath,
...(httpMethod ? { httpMethod } : {}),
...(controllerName ? { controllerName } : {}),
...(methodName ? { methodName } : {}),
...(prefix ? { prefix } : {}),
...(responseKeys ? { responseKeys } : {}),
...(errorKeys ? { errorKeys } : {}),
...(middleware && middleware.length > 0 ? { middleware } : {}),

View file

@ -0,0 +1,18 @@
export type SpringRoutePathExpression =
| { kind: 'literal'; value: string }
| { kind: 'identifier'; name: string }
| { kind: 'field-access'; ownerPath: string[]; fieldName: string };
export interface ExtractedSpringJavaRouteCandidate {
filePath: string;
controllerName: string;
methodName: string;
httpMethod: string;
classPathExpression: SpringRoutePathExpression | null;
methodPathExpression: SpringRoutePathExpression | null;
hasExplicitClassPath: boolean;
hasExplicitMethodPath: boolean;
lineNumber: number;
}
export type ExtractedDeferredRouteCandidate = ExtractedSpringJavaRouteCandidate;

View file

@ -1,8 +1,22 @@
import type Parser from 'tree-sitter';
import type { ResolutionContext } from '../resolution-context.js';
import type { SymbolDefinition } from '../symbol-table.js';
import type { ExtractedRoute } from '../workers/parse-worker.js';
import type {
ExtractedSpringJavaRouteCandidate,
SpringRoutePathExpression,
} from './spring-java-types.js';
import { extractJavaStringLiteral } from '../utils/java-strings.js';
import { findChild, type SyntaxNode } from '../utils/ast-helpers.js';
const CONTROLLER_ANNOTATIONS = new Set(['Controller', 'RestController']);
const CLASS_DECLARATION_TYPES = new Set([
'class_declaration',
'record_declaration',
'interface_declaration',
'enum_declaration',
]);
const CLASS_LIKE_TYPES = new Set(['Class', 'Record', 'Interface', 'Enum']);
const SHORTCUT_HTTP_METHODS = new Map([
['GetMapping', 'GET'],
['PostMapping', 'POST'],
@ -10,27 +24,13 @@ const SHORTCUT_HTTP_METHODS = new Map([
['DeleteMapping', 'DELETE'],
['PatchMapping', 'PATCH'],
]);
const REQUEST_MAPPING_ANNOTATIONS = new Set([
'RequestMapping',
...SHORTCUT_HTTP_METHODS.keys(),
]);
const REQUEST_MAPPING_ANNOTATIONS = new Set(['RequestMapping', ...SHORTCUT_HTTP_METHODS.keys()]);
function getAnnotationName(node: SyntaxNode): string | null {
const nameNode = node.childForFieldName('name') ?? node.firstNamedChild;
return nameNode?.text ?? null;
}
function extractJavaString(node: SyntaxNode | null | undefined): string | null {
if (!node) return null;
if (node.type === 'string_fragment') return node.text;
if (node.type === 'string_literal') {
const fragment = node.namedChildren.find((child) => child.type === 'string_fragment');
if (fragment) return fragment.text;
return node.text.replace(/^"/, '').replace(/"$/, '');
}
return null;
}
function getElementValuePairParts(node: SyntaxNode): { key: string | null; value: SyntaxNode | null } {
const keyNode = node.childForFieldName('key') ?? node.namedChild(0);
const valueNode = node.childForFieldName('value') ?? node.namedChild(1);
@ -40,27 +40,75 @@ function getElementValuePairParts(node: SyntaxNode): { key: string | null; value
};
}
function extractRequestMappingPath(annotation: SyntaxNode): string | null {
const argsNode = findChild(annotation, 'annotation_argument_list');
if (!argsNode) return null;
function extractOwnerPath(node: SyntaxNode | null | undefined): string[] | null {
if (!node) return null;
if (node.type === 'identifier') return [node.text];
if (node.type !== 'field_access') return null;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const child = argsNode.namedChild(i);
if (!child) continue;
if (child.type === 'string_literal') {
return extractJavaString(child);
}
if (child.type === 'element_value_pair') {
const { key, value } = getElementValuePairParts(child);
if ((key === 'value' || key === 'path') && value) {
return extractJavaString(value);
}
const objectNode = node.childForFieldName('object') ?? node.namedChild(0);
const fieldNode = node.childForFieldName('field') ?? node.namedChild(node.namedChildCount - 1);
const objectPath = extractOwnerPath(objectNode);
if (!objectPath || fieldNode?.type !== 'identifier') return null;
return [...objectPath, fieldNode.text];
}
function extractRoutePathExpression(node: SyntaxNode | null | undefined): SpringRoutePathExpression | null {
if (!node) return null;
const literal = extractJavaStringLiteral(node);
if (literal !== undefined) return { kind: 'literal', value: literal };
if (node.type === 'identifier') {
return { kind: 'identifier', name: node.text };
}
if (node.type === 'field_access') {
const ownerPath = extractOwnerPath(node.childForFieldName('object') ?? node.namedChild(0));
const fieldNode = node.childForFieldName('field') ?? node.namedChild(node.namedChildCount - 1);
if (ownerPath && fieldNode?.type === 'identifier') {
return {
kind: 'field-access',
ownerPath,
fieldName: fieldNode.text,
};
}
}
return null;
}
function extractRequestMappingPath(annotation: SyntaxNode): {
expression: SpringRoutePathExpression | null;
hasExplicitPath: boolean;
} {
const argsNode = findChild(annotation, 'annotation_argument_list');
if (!argsNode) return { expression: null, hasExplicitPath: false };
let hasExplicitPath = false;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const child = argsNode.namedChild(i);
if (!child) continue;
const direct = extractRoutePathExpression(child);
if (direct) return { expression: direct, hasExplicitPath: true };
if (child.type === 'string_literal' || child.type === 'identifier' || child.type === 'field_access') {
return { expression: null, hasExplicitPath: true };
}
if (child.type === 'element_value_pair') {
const { key, value } = getElementValuePairParts(child);
if (key === 'value' || key === 'path') {
hasExplicitPath = true;
const fromPair = extractRoutePathExpression(value);
if (fromPair) return { expression: fromPair, hasExplicitPath: true };
}
}
}
return { expression: null, hasExplicitPath };
}
function extractRequestMethodName(annotation: SyntaxNode, annotationName: string): string {
const shortcutMethod = SHORTCUT_HTTP_METHODS.get(annotationName);
if (shortcutMethod) return shortcutMethod;
@ -129,18 +177,96 @@ function isSpringController(modifiersNode: SyntaxNode | null): boolean {
return findAnnotation(modifiersNode, CONTROLLER_ANNOTATIONS) !== null;
}
function extractClassLevelPrefix(classNode: SyntaxNode): string | null {
const modifiersNode = findChild(classNode, 'modifiers');
const requestMapping = findAnnotation(modifiersNode, new Set(['RequestMapping']));
return requestMapping ? extractRequestMappingPath(requestMapping) : null;
function walkClasses(node: SyntaxNode, visit: (classNode: SyntaxNode) => void): void {
if (CLASS_DECLARATION_TYPES.has(node.type)) visit(node);
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) walkClasses(child, visit);
}
}
function extractMethodRoute(
function firstConstantValue(defs: readonly SymbolDefinition[]): string | null {
const withConstant = defs.filter((def) => typeof def.constantValue === 'string');
return withConstant.length === 1 ? (withConstant[0].constantValue ?? null) : null;
}
function resolveUniqueClassLike(
name: string,
filePath: string,
ctx: ResolutionContext,
): SymbolDefinition | null {
const resolved = ctx.resolve(name, filePath);
if (!resolved) return null;
const classLikes = resolved.candidates.filter((candidate) => CLASS_LIKE_TYPES.has(candidate.type));
return classLikes.length === 1 ? classLikes[0] : null;
}
function resolveNamedImportConstant(
name: string,
filePath: string,
ctx: ResolutionContext,
): string | null {
const binding = ctx.namedImportMap.get(filePath)?.get(name);
if (!binding) return null;
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
return def?.constantValue ?? null;
}
function resolveFieldAccessConstant(
ownerPath: string[],
fieldName: string,
filePath: string,
ctx: ResolutionContext,
): string | null {
const ownerName = ownerPath[ownerPath.length - 1];
if (!ownerName) return null;
const ownerDef = resolveUniqueClassLike(ownerName, filePath, ctx);
if (!ownerDef) return null;
return ctx.symbols.lookupFieldByOwner(ownerDef.nodeId, fieldName)?.constantValue ?? null;
}
function resolvePathExpression(
expression: SpringRoutePathExpression | null,
filePath: string,
className: string,
classPrefix: string | null,
ctx: ResolutionContext,
): string | null {
if (!expression) return null;
switch (expression.kind) {
case 'literal':
return expression.value;
case 'identifier': {
const sameClass = resolveUniqueClassLike(className, filePath, ctx);
if (sameClass) {
const local = ctx.symbols.lookupFieldByOwner(sameClass.nodeId, expression.name)?.constantValue;
if (local) return local;
}
const imported = resolveNamedImportConstant(expression.name, filePath, ctx);
if (imported) return imported;
const sameFile = firstConstantValue(
ctx.symbols.lookupExactAll(filePath, expression.name).filter((def) => def.type === 'Property'),
);
if (sameFile) return sameFile;
const resolved = ctx.resolve(expression.name, filePath);
return resolved ? firstConstantValue(resolved.candidates) : null;
}
case 'field-access':
return resolveFieldAccessConstant(expression.ownerPath, expression.fieldName, filePath, ctx);
}
}
function buildSpringRouteCandidate(
filePath: string,
className: string,
classPathExpression: SpringRoutePathExpression | null,
hasExplicitClassPath: boolean,
methodNode: SyntaxNode,
): ExtractedRoute | null {
): ExtractedSpringJavaRouteCandidate | null {
const modifiersNode = findChild(methodNode, 'modifiers');
if (!modifiersNode) return null;
@ -151,30 +277,27 @@ function extractMethodRoute(
const methodName = methodNode.childForFieldName('name')?.text ?? null;
if (!annotationName || !methodName) return null;
const methodPath = extractRequestMappingPath(mappingAnnotation);
const { expression: methodPathExpression, hasExplicitPath: hasExplicitMethodPath } =
extractRequestMappingPath(mappingAnnotation);
return {
filePath,
httpMethod: extractRequestMethodName(mappingAnnotation, annotationName),
routePath: joinRoutePaths(classPrefix, methodPath),
controllerName: className,
methodName,
middleware: [],
prefix: normalizePath(classPrefix),
httpMethod: extractRequestMethodName(mappingAnnotation, annotationName),
classPathExpression,
methodPathExpression,
hasExplicitClassPath,
hasExplicitMethodPath,
lineNumber: mappingAnnotation.startPosition.row,
};
}
function walkClasses(node: SyntaxNode, visit: (classNode: SyntaxNode) => void): void {
if (node.type === 'class_declaration') visit(node);
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) walkClasses(child, visit);
}
}
export function extractSpringJavaRoutes(tree: Parser.Tree, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
export function extractSpringJavaRouteCandidates(
tree: Parser.Tree,
filePath: string,
): ExtractedSpringJavaRouteCandidate[] {
const candidates: ExtractedSpringJavaRouteCandidate[] = [];
walkClasses(tree.rootNode, (classNode) => {
const modifiersNode = findChild(classNode, 'modifiers');
@ -184,14 +307,63 @@ export function extractSpringJavaRoutes(tree: Parser.Tree, filePath: string): Ex
const classBody = classNode.childForFieldName('body');
if (!className || !classBody) return;
const classPrefix = extractClassLevelPrefix(classNode);
const requestMapping = findAnnotation(modifiersNode, new Set(['RequestMapping']));
const classPath = requestMapping
? extractRequestMappingPath(requestMapping)
: { expression: null, hasExplicitPath: false };
for (let i = 0; i < classBody.namedChildCount; i++) {
const child = classBody.namedChild(i);
if (!child || child.type !== 'method_declaration') continue;
const route = extractMethodRoute(filePath, className, classPrefix, child);
if (route) routes.push(route);
const candidate = buildSpringRouteCandidate(
filePath,
className,
classPath.expression,
classPath.hasExplicitPath,
child,
);
if (candidate) candidates.push(candidate);
}
});
return candidates;
}
export function finalizeSpringJavaRoutes(
candidates: ExtractedSpringJavaRouteCandidate[],
ctx: ResolutionContext,
): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
for (const candidate of candidates) {
const classPrefix = resolvePathExpression(
candidate.classPathExpression,
candidate.filePath,
candidate.controllerName,
ctx,
);
const methodPath = resolvePathExpression(
candidate.methodPathExpression,
candidate.filePath,
candidate.controllerName,
ctx,
);
if (candidate.hasExplicitClassPath && classPrefix === null) continue;
if (candidate.hasExplicitMethodPath && methodPath === null) continue;
if (classPrefix === null && methodPath === null) continue;
routes.push({
filePath: candidate.filePath,
httpMethod: candidate.httpMethod,
routePath: joinRoutePaths(classPrefix, methodPath),
controllerName: candidate.controllerName,
methodName: candidate.methodName,
middleware: [],
prefix: normalizePath(classPrefix),
lineNumber: candidate.lineNumber,
});
}
return routes;
}

View file

@ -16,6 +16,8 @@ export interface SymbolDefinition {
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Compile-time constant string value for properties when available */
constantValue?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
@ -35,6 +37,7 @@ export interface SymbolTable {
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
constantValue?: string;
ownerId?: string;
},
) => void;
@ -122,6 +125,7 @@ export const createSymbolTable = (): SymbolTable => {
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
constantValue?: string;
ownerId?: string;
},
) => {
@ -140,6 +144,7 @@ export const createSymbolTable = (): SymbolTable => {
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.constantValue !== undefined ? { constantValue: metadata.constantValue } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};

View file

@ -0,0 +1,10 @@
import type { SyntaxNode } from './ast-helpers.js';
export function extractJavaStringLiteral(
node: Pick<SyntaxNode, 'type' | 'text' | 'namedChildren'> | null | undefined,
): string | undefined {
if (!node || node.type !== 'string_literal') return undefined;
const fragment = node.namedChildren.find((child) => child.type === 'string_fragment');
if (fragment) return fragment.text;
return node.text.replace(/^"/, '').replace(/"$/, '');
}

View file

@ -39,6 +39,7 @@ try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import type { ExtractedDeferredRouteCandidate } from '../route-extractors/spring-java-types.js';
import {
FUNCTION_NODE_TYPES,
extractFunctionName,
@ -63,7 +64,6 @@ import { extractParsedCallSite } from '../call-sites/extract-language-call-site.
import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { extractSpringJavaRoutes } from '../route-extractors/spring-java.js';
import { generateId } from '../../../lib/utils.js';
import { preprocessImportPath } from '../import-processor.js';
import type { NamedBinding } from '../named-bindings/types.js';
@ -97,6 +97,7 @@ interface ParsedNode {
visibility?: string;
isStatic?: boolean;
isReadonly?: boolean;
constantValue?: string;
};
}
@ -119,6 +120,7 @@ interface ParsedSymbol {
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
constantValue?: string;
ownerId?: string;
visibility?: string;
isStatic?: boolean;
@ -245,6 +247,7 @@ export interface ParseWorkerResult {
routes: ExtractedRoute[];
fetchCalls: ExtractedFetchCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
deferredRouteCandidates: ExtractedDeferredRouteCandidate[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
constructorBindings: FileConstructorBindings[];
@ -529,6 +532,7 @@ const processBatch = (
routes: [],
fetchCalls: [],
decoratorRoutes: [],
deferredRouteCandidates: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
@ -1701,6 +1705,7 @@ const processFileGroup = (
let visibility: string | undefined;
let isStatic: boolean | undefined;
let isReadonly: boolean | undefined;
let constantValue: string | undefined;
let isAbstract: boolean | undefined;
let isFinal: boolean | undefined;
let isVirtual: boolean | undefined;
@ -1785,6 +1790,7 @@ const processFileGroup = (
visibility = info.visibility;
isStatic = info.isStatic;
isReadonly = info.isReadonly;
constantValue = info.constantValue;
}
}
}
@ -1816,6 +1822,7 @@ const processFileGroup = (
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(constantValue !== undefined ? { constantValue } : {}),
...(visibility !== undefined ? { visibility } : {}),
...(isStatic !== undefined ? { isStatic } : {}),
...(isReadonly !== undefined ? { isReadonly } : {}),
@ -1850,6 +1857,7 @@ const processFileGroup = (
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(constantValue !== undefined ? { constantValue } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
...(visibility !== undefined ? { visibility } : {}),
...(isStatic !== undefined ? { isStatic } : {}),
@ -1893,10 +1901,11 @@ const processFileGroup = (
let extractedRoutes: ExtractedRoute[] = [];
if (language === SupportedLanguages.PHP) {
extractedRoutes = extractLaravelRoutes(tree, file.path);
} else if (language === SupportedLanguages.Java) {
extractedRoutes = extractSpringJavaRoutes(tree, file.path);
}
result.routes.push(...extractedRoutes);
if (provider.deferredRouteExtractor) {
result.deferredRouteCandidates.push(...provider.deferredRouteExtractor(tree, file.path));
}
}
// Extract ORM queries (Prisma, Supabase)
@ -1920,6 +1929,7 @@ let accumulated: ParseWorkerResult = {
routes: [],
fetchCalls: [],
decoratorRoutes: [],
deferredRouteCandidates: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
@ -1940,6 +1950,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.routes.push(...src.routes);
target.fetchCalls.push(...src.fetchCalls);
target.decoratorRoutes.push(...src.decoratorRoutes);
target.deferredRouteCandidates.push(...src.deferredRouteCandidates);
target.toolDefs.push(...src.toolDefs);
target.ormQueries.push(...src.ormQueries);
target.constructorBindings.push(...src.constructorBindings);
@ -1991,6 +2002,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
routes: [],
fetchCalls: [],
decoratorRoutes: [],
deferredRouteCandidates: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],

View file

@ -0,0 +1,9 @@
package com.example.constants;
public final class ApiPaths {
public static final String API_PREFIX = "/api";
public static final String SEARCH = "/users/search";
public static final String FQCN = "/users/fqcn";
private ApiPaths() {}
}

View file

@ -0,0 +1,8 @@
package com.example.constants;
public final class HealthPaths {
public static final String HEALTH = "/health";
public static final String STATUS = "/status";
private HealthPaths() {}
}

View file

@ -1,17 +1,20 @@
package com.example.controller;
import static com.example.constants.HealthPaths.HEALTH;
import static com.example.constants.HealthPaths.STATUS;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
@RequestMapping("/status")
@RequestMapping(STATUS)
public String status() {
return "ok";
}
@RequestMapping(path = "/health", method = RequestMethod.GET)
@RequestMapping(path = HEALTH, method = RequestMethod.GET)
public String health() {
return "ok";
}

View file

@ -1,5 +1,6 @@
package com.example.controller;
import com.example.constants.ApiPaths;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
@ -8,25 +9,47 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
@RequestMapping(ApiPaths.API_PREFIX)
public class UserController {
@GetMapping("/users")
private static final String USERS = "/users";
private static final String PROFILE = "/users/profile";
@GetMapping(USERS)
public String listUsers() {
return "users";
}
@PostMapping(path = "/users/create")
@PostMapping(path = UserPaths.CREATE)
public String createUser() {
return "created";
}
@PatchMapping("/users/profile")
@PatchMapping(PROFILE)
public String updateProfile() {
return "updated";
}
@RequestMapping(value = "/users/search", method = RequestMethod.POST)
@RequestMapping(value = ApiPaths.SEARCH, method = RequestMethod.POST)
public String searchUsers() {
return "search";
}
@RequestMapping(path = com.example.constants.ApiPaths.FQCN, method = RequestMethod.PUT)
public String fullyQualifiedUsers() {
return "fqcn";
}
@GetMapping(BrokenPaths.MISSING)
public String brokenUsers() {
return "broken";
}
@RequestMapping(path = { "/users/array" }, method = RequestMethod.DELETE)
public String arrayUsers() {
return "array";
}
}
class UserPaths {
static final String CREATE = "/users/create";
}

View file

@ -4,6 +4,7 @@ import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
@ -15,20 +16,63 @@ describe('Spring route mapping', () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'spring-route-mapping'), () => {});
}, 60000);
it('creates Route nodes for Spring request mappings', () => {
it('creates Route nodes for supported Spring request mappings only', () => {
const routes = getNodesByLabel(result, 'Route');
expect(routes).toContain('/api/users');
expect(routes).toContain('/api/users/create');
expect(routes).toContain('/api/users/profile');
expect(routes).toContain('/api/users/search');
expect(routes).toContain('/api/users/fqcn');
expect(routes).toContain('/health');
expect(routes).toContain('/status');
expect(routes).not.toContain('/');
expect(routes).not.toContain('/api');
expect(routes).not.toContain('/api/users/array');
});
it('stores Spring route metadata on Route nodes', () => {
const routes = getNodesByLabelFull(result, 'Route');
const users = routes.find((r) => r.name === '/api/users');
const create = routes.find((r) => r.name === '/api/users/create');
const profile = routes.find((r) => r.name === '/api/users/profile');
const search = routes.find((r) => r.name === '/api/users/search');
const fqcn = routes.find((r) => r.name === '/api/users/fqcn');
const health = routes.find((r) => r.name === '/health');
const status = routes.find((r) => r.name === '/status');
expect(users).toBeDefined();
expect(users!.properties.httpMethod).toBe('GET');
expect(users!.properties.controllerName).toBe('UserController');
expect(users!.properties.methodName).toBe('listUsers');
expect(users!.properties.prefix).toBe('/api');
expect(create!.properties.httpMethod).toBe('POST');
expect(create!.properties.methodName).toBe('createUser');
expect(profile!.properties.httpMethod).toBe('PATCH');
expect(profile!.properties.methodName).toBe('updateProfile');
expect(search!.properties.httpMethod).toBe('POST');
expect(search!.properties.methodName).toBe('searchUsers');
expect(fqcn!.properties.httpMethod).toBe('PUT');
expect(fqcn!.properties.methodName).toBe('fullyQualifiedUsers');
expect(health).toBeDefined();
expect(health!.properties.httpMethod).toBe('GET');
expect(health!.properties.controllerName).toBe('HealthController');
expect(health!.properties.methodName).toBe('health');
expect(health!.properties.prefix).toBeUndefined();
expect(status).toBeDefined();
expect(status!.properties.httpMethod).toBe('GET');
expect(status!.properties.controllerName).toBe('HealthController');
expect(status!.properties.methodName).toBe('status');
});
it('creates HANDLES_ROUTE edges from controller files', () => {
const edges = getRelationships(result, 'HANDLES_ROUTE');
const usersRoute = edges.find((edge) => edge.target === '/api/users');
const searchRoute = edges.find((edge) => edge.target === '/api/users/search');
const fqcnRoute = edges.find((edge) => edge.target === '/api/users/fqcn');
const healthRoute = edges.find((edge) => edge.target === '/health');
const statusRoute = edges.find((edge) => edge.target === '/status');
@ -36,6 +80,8 @@ describe('Spring route mapping', () => {
expect(usersRoute!.sourceFilePath).toContain('UserController.java');
expect(searchRoute).toBeDefined();
expect(searchRoute!.sourceFilePath).toContain('UserController.java');
expect(fqcnRoute).toBeDefined();
expect(fqcnRoute!.sourceFilePath).toContain('UserController.java');
expect(healthRoute).toBeDefined();
expect(healthRoute!.sourceFilePath).toContain('HealthController.java');
expect(statusRoute).toBeDefined();
@ -47,9 +93,18 @@ describe('Spring route mapping', () => {
expect(
edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'listUsers'),
).toBe(true);
expect(
edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'createUser'),
).toBe(true);
expect(
edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'updateProfile'),
).toBe(true);
expect(
edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'searchUsers'),
).toBe(true);
expect(
edges.some((edge) => edge.source === 'UserController.java' && edge.target === 'fullyQualifiedUsers'),
).toBe(true);
expect(
edges.some((edge) => edge.source === 'HealthController.java' && edge.target === 'health'),
).toBe(true);