diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 34226431f..90fe7a62d 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -20,7 +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 { ExtractedDeferredRouteCandidate } from './route-extractors/spring-java-types.js'; +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). */ @@ -150,7 +152,13 @@ interface LanguageProviderConfig { readonly deferredRouteExtractor?: ( tree: Parser.Tree, filePath: string, - ) => ExtractedDeferredRouteCandidate[]; + ) => 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 cc3d0dfe7..bb4040bd8 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -17,15 +17,26 @@ 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 { + 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 ( - normalized.endsWith('Controller.java') || - normalized.includes('/controller/') || - normalized.includes('/controllers/') + SPRING_ROUTE_FILE_SUFFIXES.some((suffix) => normalized.endsWith(suffix)) || + SPRING_ROUTE_DIR_HINTS.some((segment) => normalized.includes(segment)) ); } @@ -43,4 +54,5 @@ export const javaProvider = defineLanguage({ 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 1d8f0affb..7465b3d12 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -18,7 +18,7 @@ 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 { 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'; @@ -49,7 +49,7 @@ export interface WorkerExtractedData { routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; - deferredRouteCandidates: ExtractedDeferredRouteCandidate[]; + deferredRouteCandidates: DeferredRouteCandidate[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; @@ -112,7 +112,7 @@ const processParsingWithWorkers = async ( const allRoutes: ExtractedRoute[] = []; const allFetchCalls: ExtractedFetchCall[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; - const allDeferredRouteCandidates: ExtractedDeferredRouteCandidate[] = []; + const allDeferredRouteCandidates: DeferredRouteCandidate[] = []; const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; const allConstructorBindings: FileConstructorBindings[] = []; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 70ff87027..5501e4f6a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -25,8 +25,7 @@ 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 type { DeferredRouteCandidate } from './route-extractors/deferred-route-types.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; import { @@ -786,7 +785,7 @@ async function runChunkedParseAndResolve( const allExtractedRoutes: ExtractedRoute[] = []; // Accumulate decorator-based routes (@Get, @Post, @app.route, etc.) const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; - const allDeferredRouteCandidates: ExtractedDeferredRouteCandidate[] = []; + const allDeferredRouteCandidates: DeferredRouteCandidate[] = []; // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; @@ -1032,10 +1031,17 @@ async function runChunkedParseAndResolve( astCache.clear(); } - const finalizedSpringRoutes = finalizeSpringJavaRoutes(allDeferredRouteCandidates, ctx); - if (finalizedSpringRoutes.length > 0) { - await processRoutesFromExtracted(graph, finalizedSpringRoutes, ctx); - allExtractedRoutes.push(...finalizedSpringRoutes); + 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 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 000000000..26e5b8045 --- /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 index 81d8bfbbc..5bc5edb94 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java-types.ts @@ -1,9 +1,14 @@ +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 { +export interface ExtractedSpringJavaRouteCandidate extends DeferredRouteCandidate { + kind: 'spring-java'; + language: SupportedLanguages.Java; filePath: string; controllerName: string; methodName: string; @@ -14,5 +19,3 @@ export interface ExtractedSpringJavaRouteCandidate { 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 f93d8833c..f05bb3edc 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring-java.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring-java.ts @@ -1,3 +1,4 @@ +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'; @@ -6,6 +7,7 @@ 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'; @@ -227,15 +229,36 @@ function resolveNamedImportConstant( 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 ownerName = ownerPath[ownerPath.length - 1]; - if (!ownerName) return null; - const ownerDef = resolveUniqueClassLike(ownerName, filePath, ctx); + const ownerDef = resolveQualifiedClassLike(ownerPath, filePath, ctx); if (!ownerDef) return null; return ctx.symbols.lookupFieldByOwner(ownerDef.nodeId, fieldName)?.constantValue ?? null; } @@ -300,6 +323,8 @@ function buildSpringRouteCandidate( extractRequestMappingPath(mappingAnnotation); return { + kind: 'spring-java', + language: SupportedLanguages.Java, filePath, controllerName: className, methodName, @@ -348,13 +373,20 @@ export function extractSpringJavaRouteCandidates( return candidates; } +function isSpringJavaRouteCandidate( + candidate: DeferredRouteCandidate, +): candidate is ExtractedSpringJavaRouteCandidate { + return candidate.kind === 'spring-java'; +} + export function finalizeSpringJavaRoutes( - candidates: ExtractedSpringJavaRouteCandidate[], + candidates: DeferredRouteCandidate[], ctx: ResolutionContext, ): ExtractedRoute[] { const routes: ExtractedRoute[] = []; for (const candidate of candidates) { + if (!isSpringJavaRouteCandidate(candidate)) continue; const classPrefix = resolvePathExpression( candidate.classPathExpression, candidate.filePath, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index eb8a379e3..5ac4a9495 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -39,7 +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 type { DeferredRouteCandidate } from '../route-extractors/deferred-route-types.js'; import { FUNCTION_NODE_TYPES, extractFunctionName, @@ -247,7 +247,7 @@ export interface ParseWorkerResult { routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; - deferredRouteCandidates: ExtractedDeferredRouteCandidate[]; + deferredRouteCandidates: DeferredRouteCandidate[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; diff --git a/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts index ef6128e3a..0641ce9e7 100644 --- a/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts +++ b/gitnexus/test/integration/resolvers/spring-route-mapping.test.ts @@ -30,6 +30,7 @@ describe('Spring route mapping', () => { 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');