diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ec7262c3b8..41b6844cc5 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1701,7 +1701,7 @@ export const processAssignmentsFromExtracted = ( }; /** - * Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods. + * Resolve pre-extracted framework routes to CALLS edges from handler files to controller methods. */ export const processRoutesFromExtracted = async ( graph: KnowledgeGraph, @@ -1739,7 +1739,7 @@ export const processRoutesFromExtracted = async ( targetId: guessedId, type: 'CALLS', confidence: confidence * 0.8, - reason: 'laravel-route', + reason: 'framework-route', }); continue; } @@ -1751,7 +1751,7 @@ export const processRoutesFromExtracted = async ( targetId: methodId, type: 'CALLS', confidence, - reason: 'laravel-route', + reason: 'framework-route', }); } diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts index 9d6cdbf073..f0dad2e53f 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 4cb4a5b1b3..753a1df484 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 5e89d34bb5..be301c6627 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 070a4acb06..90fe7a62d1 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,9 @@ 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 { DeferredRouteCandidate } from './route-extractors/deferred-route-types.js'; +import type { ResolutionContext } from './resolution-context.js'; +import type { ExtractedRoute } from './workers/parse-worker.js'; // ── Shared type aliases ──────────────────────────────────────────────────── /** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */ @@ -143,6 +147,18 @@ 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, + ) => DeferredRouteCandidate[]; + /** Finalize deferred route candidates after imports/symbols are available. + * Default: undefined (no deferred route finalization). */ + readonly deferredRouteFinalizer?: ( + candidates: DeferredRouteCandidate[], + ctx: ResolutionContext, + ) => ExtractedRoute[]; // ── 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 8f18056d3c..bb4040bd84 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -17,8 +17,29 @@ 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, + finalizeSpringJavaRoutes, +} from '../route-extractors/spring-java.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; +const SPRING_ROUTE_FILE_SUFFIXES = [ + 'Controller.java', + 'Resource.java', + 'Endpoint.java', + 'Api.java', + 'Handler.java', +]; +const SPRING_ROUTE_DIR_HINTS = ['/controller/', '/controllers/', '/rest/', '/api/', '/web/']; + +function isSpringRouteFile(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + return ( + SPRING_ROUTE_FILE_SUFFIXES.some((suffix) => normalized.endsWith(suffix)) || + SPRING_ROUTE_DIR_HINTS.some((segment) => normalized.includes(segment)) + ); +} + export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, extensions: ['.java'], @@ -31,4 +52,7 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), + isRouteFile: isSpringRouteFile, + deferredRouteExtractor: extractSpringJavaRouteCandidates, + deferredRouteFinalizer: finalizeSpringJavaRoutes, }); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 242909064f..7465b3d126 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -18,9 +18,11 @@ import { } from './utils/ast-helpers.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { buildTypeEnv } from './type-env.js'; +import type { DeferredRouteCandidate } from './route-extractors/deferred-route-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: DeferredRouteCandidate[]; 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: DeferredRouteCandidate[] = []; 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,12 @@ const processParsingSequential = async ( }); } }); + + if (provider.isRouteFile?.(file.path) && provider.deferredRouteExtractor) { + extractedData.deferredRouteCandidates.push( + ...provider.deferredRouteExtractor(tree, file.path), + ); + } } if (skippedLanguages.size > 0) { @@ -495,12 +516,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 +536,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 +547,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 +559,9 @@ 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 5f7d21fe1e..5501e4f6a8 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -25,6 +25,7 @@ import { mergeImplementorMaps, } from './call-processor.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; +import type { DeferredRouteCandidate } from './route-extractors/deferred-route-types.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; import { @@ -784,6 +785,7 @@ async function runChunkedParseAndResolve( const allExtractedRoutes: ExtractedRoute[] = []; // Accumulate decorator-based routes (@Get, @Post, @app.route, etc.) const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + const allDeferredRouteCandidates: DeferredRouteCandidate[] = []; // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; @@ -803,7 +805,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, @@ -827,13 +829,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({ @@ -863,7 +868,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, ); @@ -873,17 +878,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), @@ -896,7 +901,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), @@ -911,24 +916,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); @@ -1026,6 +1031,19 @@ async function runChunkedParseAndResolve( astCache.clear(); } + for (const provider of Object.values(providers)) { + if (!provider.deferredRouteFinalizer) continue; + + const finalizedRoutes = provider.deferredRouteFinalizer( + allDeferredRouteCandidates.filter((candidate) => candidate.language === provider.id), + ctx, + ); + if (finalizedRoutes.length === 0) continue; + + await processRoutesFromExtracted(graph, finalizedRoutes, ctx); + allExtractedRoutes.push(...finalizedRoutes); + } + // Log resolution cache stats if (isDev) { const rcStats = ctx.getStats(); @@ -1355,7 +1373,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). @@ -1414,12 +1439,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, }); } @@ -1429,7 +1459,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 @@ -1448,6 +1485,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/deferred-route-types.ts b/gitnexus/src/core/ingestion/route-extractors/deferred-route-types.ts new file mode 100644 index 0000000000..26e5b80458 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/deferred-route-types.ts @@ -0,0 +1,8 @@ +import type { SupportedLanguages } from 'gitnexus-shared'; + +export interface DeferredRouteCandidate { + kind: string; + language: SupportedLanguages; + filePath: string; + lineNumber: number; +} 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 0000000000..5bc5edb940 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts @@ -0,0 +1,21 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { DeferredRouteCandidate } from './deferred-route-types.js'; + +export type SpringRoutePathExpression = + | { kind: 'literal'; value: string } + | { kind: 'identifier'; name: string } + | { kind: 'field-access'; ownerPath: string[]; fieldName: string }; + +export interface ExtractedSpringJavaRouteCandidate extends DeferredRouteCandidate { + kind: 'spring-java'; + language: SupportedLanguages.Java; + filePath: string; + controllerName: string; + methodName: string; + httpMethod: string; + classPathExpression: SpringRoutePathExpression | null; + methodPathExpression: SpringRoutePathExpression | null; + hasExplicitClassPath: boolean; + hasExplicitMethodPath: boolean; + lineNumber: number; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-java.ts b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts new file mode 100644 index 0000000000..f05bb3edce --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts @@ -0,0 +1,420 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +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 type { DeferredRouteCandidate } from './deferred-route-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'], + ['PutMapping', 'PUT'], + ['DeleteMapping', 'DELETE'], + ['PatchMapping', 'PATCH'], +]); +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 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); + return { + key: keyNode?.text ?? null, + value: valueNode ?? 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; + + 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; + if (annotationName !== 'RequestMapping') return 'GET'; + + const argsNode = findChild(annotation, 'annotation_argument_list'); + if (!argsNode) return 'GET'; + + for (let i = 0; i < argsNode.namedChildCount; i++) { + const child = argsNode.namedChild(i); + if (!child || child.type !== 'element_value_pair') continue; + const { key, value } = getElementValuePairParts(child); + if (key !== 'method' || !value) continue; + + if (value.type === 'field_access') { + return value.text.split('.').pop() ?? 'GET'; + } + + if (value.type === 'array_initializer') { + for (let j = 0; j < value.namedChildCount; j++) { + const candidate = value.namedChild(j); + if (candidate?.type === 'field_access') { + return candidate.text.split('.').pop() ?? 'GET'; + } + } + } + } + + return 'GET'; +} + +function normalizePath(path: string | null): string | null { + if (path == null) return null; + const trimmed = path.trim(); + if (!trimmed) return null; + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+/g, '/'); +} + +function joinRoutePaths(prefix: string | null, routePath: string | null): string { + const normalizedPrefix = normalizePath(prefix); + const normalizedRoutePath = normalizePath(routePath); + + if (normalizedPrefix && normalizedRoutePath) { + return `${normalizedPrefix}/${normalizedRoutePath.replace(/^\/+/, '')}`.replace(/\/+/g, '/'); + } + if (normalizedPrefix) return normalizedPrefix; + if (normalizedRoutePath) return normalizedRoutePath; + return '/'; +} + +function findAnnotation( + modifiersNode: SyntaxNode | null, + names: ReadonlySet, +): SyntaxNode | null { + if (!modifiersNode) return null; + + for (let i = 0; i < modifiersNode.namedChildCount; i++) { + const child = modifiersNode.namedChild(i); + if (!child || (child.type !== 'annotation' && child.type !== 'marker_annotation')) continue; + const name = getAnnotationName(child); + if (name && names.has(name)) return child; + } + + return null; +} + +function isSpringController(modifiersNode: SyntaxNode | null): boolean { + return findAnnotation(modifiersNode, CONTROLLER_ANNOTATIONS) !== 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 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 normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function resolveQualifiedClassLike( + ownerPath: string[], + filePath: string, + ctx: ResolutionContext, +): SymbolDefinition | null { + const ownerName = ownerPath[ownerPath.length - 1]; + if (!ownerName) return null; + + const exact = resolveUniqueClassLike(ownerName, filePath, ctx); + if (exact || ownerPath.length === 1) return exact; + + const qualifiedSuffix = `/${ownerPath.join('/')}.java`; + const classLikes = ctx.symbols + .lookupFuzzy(ownerName) + .filter((candidate) => CLASS_LIKE_TYPES.has(candidate.type)) + .filter((candidate) => normalizeFilePath(candidate.filePath).endsWith(qualifiedSuffix)); + return classLikes.length === 1 ? classLikes[0] : null; +} + +function resolveFieldAccessConstant( + ownerPath: string[], + fieldName: string, + filePath: string, + ctx: ResolutionContext, +): string | null { + const ownerDef = resolveQualifiedClassLike(ownerPath, filePath, ctx); + if (!ownerDef) return null; + return ctx.symbols.lookupFieldByOwner(ownerDef.nodeId, fieldName)?.constantValue ?? null; +} + +function resolvePathExpression( + expression: SpringRoutePathExpression | null, + filePath: string, + className: string, + 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, +): ExtractedSpringJavaRouteCandidate | null { + const modifiersNode = findChild(methodNode, 'modifiers'); + if (!modifiersNode) return null; + + const mappingAnnotation = findAnnotation(modifiersNode, REQUEST_MAPPING_ANNOTATIONS); + if (!mappingAnnotation) return null; + + const annotationName = getAnnotationName(mappingAnnotation); + const methodName = methodNode.childForFieldName('name')?.text ?? null; + if (!annotationName || !methodName) return null; + + const { expression: methodPathExpression, hasExplicitPath: hasExplicitMethodPath } = + extractRequestMappingPath(mappingAnnotation); + + return { + kind: 'spring-java', + language: SupportedLanguages.Java, + filePath, + controllerName: className, + methodName, + httpMethod: extractRequestMethodName(mappingAnnotation, annotationName), + classPathExpression, + methodPathExpression, + hasExplicitClassPath, + hasExplicitMethodPath, + lineNumber: mappingAnnotation.startPosition.row, + }; +} + +export function extractSpringJavaRouteCandidates( + tree: Parser.Tree, + filePath: string, +): ExtractedSpringJavaRouteCandidate[] { + const candidates: ExtractedSpringJavaRouteCandidate[] = []; + + walkClasses(tree.rootNode, (classNode) => { + const modifiersNode = findChild(classNode, 'modifiers'); + if (!isSpringController(modifiersNode)) return; + + const className = classNode.childForFieldName('name')?.text; + const classBody = classNode.childForFieldName('body'); + if (!className || !classBody) return; + + 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 candidate = buildSpringRouteCandidate( + filePath, + className, + classPath.expression, + classPath.hasExplicitPath, + child, + ); + if (candidate) candidates.push(candidate); + } + }); + + return candidates; +} + +function isSpringJavaRouteCandidate( + candidate: DeferredRouteCandidate, +): candidate is ExtractedSpringJavaRouteCandidate { + return candidate.kind === 'spring-java'; +} + +export function finalizeSpringJavaRoutes( + candidates: DeferredRouteCandidate[], + ctx: ResolutionContext, +): ExtractedRoute[] { + const routes: ExtractedRoute[] = []; + + for (const candidate of candidates) { + if (!isSpringJavaRouteCandidate(candidate)) continue; + 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 3b292823b1..09d341ca2e 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 0000000000..9776d66c21 --- /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 20909db112..5ac4a94952 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 { DeferredRouteCandidate } from '../route-extractors/deferred-route-types.js'; import { FUNCTION_NODE_TYPES, extractFunctionName, @@ -96,6 +97,7 @@ interface ParsedNode { visibility?: string; isStatic?: boolean; isReadonly?: boolean; + constantValue?: string; }; } @@ -118,6 +120,7 @@ interface ParsedSymbol { parameterTypes?: string[]; returnType?: string; declaredType?: string; + constantValue?: string; ownerId?: string; visibility?: string; isStatic?: boolean; @@ -244,6 +247,7 @@ export interface ParseWorkerResult { routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; + deferredRouteCandidates: DeferredRouteCandidate[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; @@ -528,6 +532,7 @@ const processBatch = ( routes: [], fetchCalls: [], decoratorRoutes: [], + deferredRouteCandidates: [], toolDefs: [], ormQueries: [], constructorBindings: [], @@ -1700,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; @@ -1784,6 +1790,7 @@ const processFileGroup = ( visibility = info.visibility; isStatic = info.isStatic; isReadonly = info.isReadonly; + constantValue = info.constantValue; } } } @@ -1815,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 } : {}), @@ -1849,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 } : {}), @@ -1887,10 +1896,16 @@ const processFileGroup = ( } } - // Extract framework routes via provider detection (e.g., Laravel routes.php) + // Extract framework routes via provider detection (e.g., Laravel routes.php, Spring controllers) if (provider.isRouteFile?.(file.path)) { - const extractedRoutes = extractLaravelRoutes(tree, file.path); + let extractedRoutes: ExtractedRoute[] = []; + if (language === SupportedLanguages.PHP) { + extractedRoutes = extractLaravelRoutes(tree, file.path); + } result.routes.push(...extractedRoutes); + if (provider.deferredRouteExtractor) { + result.deferredRouteCandidates.push(...provider.deferredRouteExtractor(tree, file.path)); + } } // Extract ORM queries (Prisma, Supabase) @@ -1914,6 +1929,7 @@ let accumulated: ParseWorkerResult = { routes: [], fetchCalls: [], decoratorRoutes: [], + deferredRouteCandidates: [], toolDefs: [], ormQueries: [], constructorBindings: [], @@ -1934,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); @@ -1985,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 0000000000..4dc2ce4fce --- /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 0000000000..0c779417aa --- /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 new file mode 100644 index 0000000000..e210428a3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/HealthController.java @@ -0,0 +1,21 @@ +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) + public String status() { + return "ok"; + } + + @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 new file mode 100644 index 0000000000..212b5ecd5b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/spring-route-mapping/src/main/java/com/example/controller/UserController.java @@ -0,0 +1,55 @@ +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(ApiPaths.API_PREFIX) +public class UserController { + private static final String USERS = "/users"; + private static final String PROFILE = "/users/profile"; + + @GetMapping(USERS) + public String listUsers() { + return "users"; + } + + @PostMapping(path = UserPaths.CREATE) + public String createUser() { + return "created"; + } + + @PatchMapping(PROFILE) + public String updateProfile() { + return "updated"; + } + + @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 new file mode 100644 index 0000000000..05a2d14c52 --- /dev/null +++ b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + getNodesByLabelFull, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function expectDefined(value: T | undefined): T { + expect(value).toBeDefined(); + return value as T; +} + +describe('Spring route mapping', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'spring-route-mapping'), () => {}); + }, 60000); + + 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).toHaveLength(7); + + 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'); + + const usersRoute = expectDefined(users); + const createRoute = expectDefined(create); + const profileRoute = expectDefined(profile); + const searchRouteNode = expectDefined(search); + const fqcnRouteNode = expectDefined(fqcn); + const healthRouteNode = expectDefined(health); + const statusRouteNode = expectDefined(status); + + expect(usersRoute.properties.httpMethod).toBe('GET'); + expect(usersRoute.properties.controllerName).toBe('UserController'); + expect(usersRoute.properties.methodName).toBe('listUsers'); + expect(usersRoute.properties.prefix).toBe('/api'); + + expect(createRoute.properties.httpMethod).toBe('POST'); + expect(createRoute.properties.methodName).toBe('createUser'); + expect(profileRoute.properties.httpMethod).toBe('PATCH'); + expect(profileRoute.properties.methodName).toBe('updateProfile'); + expect(searchRouteNode.properties.httpMethod).toBe('POST'); + expect(searchRouteNode.properties.methodName).toBe('searchUsers'); + expect(fqcnRouteNode.properties.httpMethod).toBe('PUT'); + expect(fqcnRouteNode.properties.methodName).toBe('fullyQualifiedUsers'); + + expect(healthRouteNode.properties.httpMethod).toBe('GET'); + expect(healthRouteNode.properties.controllerName).toBe('HealthController'); + expect(healthRouteNode.properties.methodName).toBe('health'); + expect(healthRouteNode.properties.prefix).toBeUndefined(); + + expect(statusRouteNode.properties.httpMethod).toBe('GET'); + expect(statusRouteNode.properties.controllerName).toBe('HealthController'); + expect(statusRouteNode.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'); + + expect(expectDefined(usersRoute).sourceFilePath).toContain('UserController.java'); + expect(expectDefined(searchRoute).sourceFilePath).toContain('UserController.java'); + expect(expectDefined(fqcnRoute).sourceFilePath).toContain('UserController.java'); + expect(expectDefined(healthRoute).sourceFilePath).toContain('HealthController.java'); + expect(expectDefined(statusRoute).sourceFilePath).toContain('HealthController.java'); + }); + + it('skips unresolved explicit constant mappings without emitting route links', () => { + const routes = getNodesByLabel(result, 'Route'); + const handlesRouteEdges = getRelationships(result, 'HANDLES_ROUTE'); + const callEdges = getRelationships(result, 'CALLS'); + + expect(routes).toHaveLength(7); + expect( + handlesRouteEdges.some( + (edge) => + edge.sourceFilePath.includes('UserController.java') && edge.target.includes('broken'), + ), + ).toBe(false); + expect( + callEdges.some( + (edge) => edge.source === 'UserController.java' && edge.target === 'brokenUsers', + ), + ).toBe(false); + }); + + it('creates framework CALLS edges from controller files to handler methods', () => { + const edges = getRelationships(result, 'CALLS'); + 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); + expect( + edges.some((edge) => edge.source === 'HealthController.java' && edge.target === 'status'), + ).toBe(true); + }); +});