From 71735cf9b6663dac33cab2c9a5c463c14fee6a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=81=A9=E5=AE=81?= Date: Thu, 2 Apr 2026 18:55:35 +0800 Subject: [PATCH] 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 --- .../ingestion/field-extractors/configs/jvm.ts | 12 + .../ingestion/field-extractors/generic.ts | 5 + gitnexus/src/core/ingestion/field-types.ts | 2 + .../src/core/ingestion/language-provider.ts | 8 + gitnexus/src/core/ingestion/languages/java.ts | 2 + .../src/core/ingestion/parsing-processor.ts | 82 +++-- gitnexus/src/core/ingestion/pipeline.ts | 99 ++++--- .../route-extractors/spring-java-types.ts | 18 ++ .../ingestion/route-extractors/spring-java.ts | 280 ++++++++++++++---- gitnexus/src/core/ingestion/symbol-table.ts | 5 + .../src/core/ingestion/utils/java-strings.ts | 10 + .../core/ingestion/workers/parse-worker.ts | 18 +- .../java/com/example/constants/ApiPaths.java | 9 + .../com/example/constants/HealthPaths.java | 8 + .../example/controller/HealthController.java | 7 +- .../example/controller/UserController.java | 33 ++- .../resolvers/spring-route-mapping.test.ts | 57 +++- 17 files changed, 529 insertions(+), 126 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts create mode 100644 gitnexus/src/core/ingestion/utils/java-strings.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/ApiPaths.java create mode 100644 gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/HealthPaths.java diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts index 9d6cdbf07..f0dad2e53 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts @@ -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); + }, }; // --------------------------------------------------------------------------- diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index 4cb4a5b1b..753a1df48 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -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, }; diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 5e89d34bb..be301c662 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -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 */ diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 070a4acb0..34226431f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 9f339f689..cc3d0dfe7 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 242909064..e820f90c9 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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 => { const parser = await loadParser(); + const extractedData = createEmptyExtractedData(); const total = files.length; const skippedLanguages = new Map(); @@ -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 => { +): Promise => { 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, + }; }; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 376a3126a..70ff87027 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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(); // 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 } : {}), diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts b/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts new file mode 100644 index 000000000..81d8bfbbc --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-java.ts b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts index e509ced64..dec74cf74 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring-java.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 3b292823b..09d341ca2 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -16,6 +16,8 @@ export interface SymbolDefinition { returnType?: string; /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ 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 } : {}), }; diff --git a/gitnexus/src/core/ingestion/utils/java-strings.ts b/gitnexus/src/core/ingestion/utils/java-strings.ts new file mode 100644 index 000000000..9776d66c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/java-strings.ts @@ -0,0 +1,10 @@ +import type { SyntaxNode } from './ast-helpers.js'; + +export function extractJavaStringLiteral( + node: Pick | 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(/"$/, ''); +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index be4c10714..eb8a379e3 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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: [], diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/ApiPaths.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/ApiPaths.java new file mode 100644 index 000000000..4dc2ce4fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/ApiPaths.java @@ -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() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/HealthPaths.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/HealthPaths.java new file mode 100644 index 000000000..0c779417a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/constants/HealthPaths.java @@ -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() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java index 83fdc7208..e210428a3 100644 --- a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java @@ -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"; } diff --git a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java index 16aaa01ff..212b5ecd5 100644 --- a/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java @@ -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"; } diff --git a/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts index f28e2a656..028b7c93e 100644 --- a/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts +++ b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts @@ -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);