refactor: generalize deferred route finalization

Move deferred route finalization behind language provider hooks so Spring-specific candidate handling no longer leaks into the shared pipeline contract. Also broaden Java controller heuristics and preserve FQCN-backed Spring constant resolution.

Made-with: Cursor
This commit is contained in:
许恩宁 2026-04-03 10:16:50 +08:00
parent 59db54f284
commit 70f39eabca
9 changed files with 95 additions and 25 deletions

View file

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

View file

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

View file

@ -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[] = [];

View file

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

View file

@ -0,0 +1,8 @@
import type { SupportedLanguages } from 'gitnexus-shared';
export interface DeferredRouteCandidate {
kind: string;
language: SupportedLanguages;
filePath: string;
lineNumber: number;
}

View file

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

View file

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

View file

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

View file

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