diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index 37b5f2b14..ca79ed745 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -43,3 +43,10 @@ export const SPRING_AOP_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), }; + +/** Durable completeness contract for scheduled, event, messaging, and job entry points (#2417). */ +export const SPRING_NON_HTTP_HANDLERS_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.non-http-handlers', + version: 1, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts new file mode 100644 index 000000000..8f3144d01 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts @@ -0,0 +1,219 @@ +import type { GraphNode, ParsedFile, Range, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { resolveCallerGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; +import { SPRING_BEAN_ANNOTATION } from './bean-factories.js'; + +export const SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER = 3.0; + +export type SpringNonHttpHandlerKind = 'scheduled' | 'event' | 'message' | 'xxl-job'; + +export interface SpringNonHttpHandlerAnnotationFact { + readonly name: string; + /** Kotlin use-site targets describe generated/property elements, not the callable. */ + readonly useSiteTarget?: string; +} + +export interface SpringNonHttpHandlerFact< + Annotation extends SpringNonHttpHandlerAnnotationFact = SpringNonHttpHandlerAnnotationFact, +> { + readonly ownerScopeId: ScopeId; + readonly ownerFilePath?: string; + /** Exact syntax range used only as a fail-closed bridge for collapsed language scopes. */ + readonly ownerRange?: Range; + readonly annotations: readonly Annotation[]; +} + +export interface SpringNonHttpHandlerAdapter< + Annotation extends SpringNonHttpHandlerAnnotationFact, +> { + getFacts(filePath: string): readonly SpringNonHttpHandlerFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; +} + +const SPRING_SERVICE_ACTIVATOR_ANNOTATION = + 'org.springframework.integration.annotation.ServiceActivator'; + +const HANDLER_ANNOTATIONS = new Map([ + ['org.springframework.scheduling.annotation.Scheduled', 'scheduled'], + ['org.springframework.scheduling.annotation.Schedules', 'scheduled'], + ['org.springframework.context.event.EventListener', 'event'], + ['org.springframework.transaction.event.TransactionalEventListener', 'event'], + ['org.springframework.modulith.events.ApplicationModuleListener', 'event'], + ['org.springframework.kafka.annotation.KafkaListener', 'message'], + ['org.springframework.kafka.annotation.KafkaListeners', 'message'], + ['org.springframework.amqp.rabbit.annotation.RabbitListener', 'message'], + ['org.springframework.amqp.rabbit.annotation.RabbitListeners', 'message'], + ['org.springframework.jms.annotation.JmsListener', 'message'], + ['org.springframework.jms.annotation.JmsListeners', 'message'], + ['org.springframework.pulsar.annotation.PulsarListener', 'message'], + ['org.springframework.pulsar.annotation.PulsarListeners', 'message'], + ['io.awspring.cloud.sqs.annotation.SqsListener', 'message'], + ['io.awspring.cloud.messaging.listener.annotation.SqsListener', 'message'], + ['org.springframework.cloud.aws.messaging.listener.annotation.SqsListener', 'message'], + ['org.springframework.cloud.stream.annotation.StreamListener', 'message'], + [SPRING_SERVICE_ACTIVATOR_ANNOTATION, 'message'], + ['org.springframework.messaging.handler.annotation.MessageMapping', 'message'], + ['org.springframework.messaging.simp.annotation.SubscribeMapping', 'message'], + ['com.xxl.job.core.handler.annotation.XxlJob', 'xxl-job'], +]); + +const RECOGNIZED_HANDLER_ANNOTATIONS = new Set(HANDLER_ANNOTATIONS.keys()); +const RESOLVABLE_NON_HTTP_ANNOTATIONS = new Set([ + ...RECOGNIZED_HANDLER_ANNOTATIONS, + SPRING_BEAN_ANNOTATION, +]); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set([...RECOGNIZED_HANDLER_ANNOTATIONS].map(simpleName)); + +export function hasSpringNonHttpHandlerRelevantAnnotation( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)), + ); +} + +function exactCallableOwnersByRange(graph: KnowledgeGraph): ReadonlyMap { + const owners = new Map(); + for (const node of graph.iterNodes()) { + if ( + (node.label !== 'Method' && node.label !== 'Function') || + typeof node.properties.filePath !== 'string' + ) { + continue; + } + const key = `${node.properties.filePath}\0${node.properties.startLine}\0${node.properties.endLine}`; + owners.set(key, owners.has(key) ? null : node); + } + return owners; +} + +function ownerGraphNode( + fact: SpringNonHttpHandlerFact, + indexes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + graph: KnowledgeGraph, + getExactOwnerByRange: () => ReadonlyMap, +): GraphNode | undefined { + const ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup); + if (ownerId !== undefined) { + const owner = graph.getNode(ownerId); + if (owner?.label === 'Method' || owner?.label === 'Function') return owner; + } + if (fact.ownerFilePath !== undefined && fact.ownerRange !== undefined) { + const fallback = getExactOwnerByRange().get( + `${fact.ownerFilePath}\0${fact.ownerRange.startLine - 1}\0${fact.ownerRange.endLine - 1}`, + ); + if (fallback !== null && fallback !== undefined) return fallback; + } + return undefined; +} + +function handlerReason(kinds: ReadonlySet): string { + if (kinds.size !== 1) { + return kinds.has('xxl-job') ? 'managed-non-http-handler' : 'spring-non-http-handler'; + } + const kind = kinds.values().next().value; + if (kind === 'xxl-job') return 'xxl-job-handler'; + return `spring-${kind}-handler`; +} + +/** + * Resolve callable annotations after imports and package visibility finalize, + * then promote confirmed framework-managed handlers into process entry points. + */ +export function createSpringNonHttpHandlerMetadataAttacher< + Annotation extends SpringNonHttpHandlerAnnotationFact, +>(adapter: SpringNonHttpHandlerAdapter) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const factsByFile = new Map[]>(); + for (const parsed of parsedFiles) { + const facts = adapter.getFacts(parsed.filePath); + if (facts.length > 0) factsByFile.set(parsed.filePath, facts); + } + if (factsByFile.size === 0) return; + + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + let exactOwnerByRange: ReadonlyMap | undefined; + const getExactOwnerByRange = (): ReadonlyMap => + (exactOwnerByRange ??= exactCallableOwnersByRange(graph)); + let classIdByMethod: ReadonlyMap | undefined; + const ownerClassLabel = (methodId: string): GraphNode['label'] | undefined => { + if (classIdByMethod === undefined) { + const owners = new Map(); + for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) { + owners.set(relationship.targetId, relationship.sourceId); + } + classIdByMethod = owners; + } + const classId = classIdByMethod.get(methodId); + return classId === undefined ? undefined : graph.getNode(classId)?.label; + }; + + for (const parsed of parsedFiles) { + const facts = factsByFile.get(parsed.filePath); + if (facts === undefined) continue; + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + const resolvedAnnotations = new Map(); + for (const fact of facts) { + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + const resolvedFactAnnotations = new Set(); + for (const annotation of fact.annotations) { + if (annotation.useSiteTarget !== undefined) continue; + const enclosingScope = ownerScope?.parent ?? null; + const cacheKey = `${enclosingScope ?? ''}\0${annotation.name}`; + let resolved = resolvedAnnotations.get(cacheKey); + if (!resolvedAnnotations.has(cacheKey)) { + resolved = resolveAnnotation( + annotation.name, + parsed, + enclosingScope, + RESOLVABLE_NON_HTTP_ANNOTATIONS, + incomplete, + ); + resolvedAnnotations.set(cacheKey, resolved); + } + if (resolved !== undefined) resolvedFactAnnotations.add(resolved); + } + + const beanFactoryMethod = resolvedFactAnnotations.has(SPRING_BEAN_ANNOTATION); + const kinds = new Set(); + for (const resolved of resolvedFactAnnotations) { + if (beanFactoryMethod && resolved === SPRING_SERVICE_ACTIVATOR_ANNOTATION) continue; + const kind = HANDLER_ANNOTATIONS.get(resolved); + if (kind !== undefined) kinds.add(kind); + } + if (kinds.size === 0) continue; + + const owner = ownerGraphNode(fact, indexes, nodeLookup, graph, getExactOwnerByRange); + if (owner === undefined || ownerClassLabel(owner.id) === 'Interface') continue; + + const currentMultiplier = owner.properties.astFrameworkMultiplier ?? 1.0; + owner.properties.astFrameworkMultiplier = Math.max( + currentMultiplier, + SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER, + ); + if ( + currentMultiplier < SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER || + (currentMultiplier === SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER && + owner.properties.astFrameworkReason === undefined) + ) { + owner.properties.astFrameworkReason = handlerReason(kinds); + } + } + } + }; +} diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 7a7f82685..91a910fa5 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -13,6 +13,7 @@ import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; +import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -24,6 +25,7 @@ export interface JavaCaptureSideChannel { readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; + readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[]; } const classAnnotations = createClassAnnotationFactStore(); @@ -31,6 +33,7 @@ const springAopFacts = new Map(); const springConfigConsumers = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springNonHttpHandlerFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { @@ -39,6 +42,7 @@ export function clearJavaClassAnnotationFacts(): void { springConfigConsumers.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springNonHttpHandlerFacts.clear(); } export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void { @@ -98,6 +102,20 @@ export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiCla return springDiFacts.get(filePath) ?? []; } +export function setJavaSpringNonHttpHandlerFacts( + filePath: string, + facts: readonly JavaSpringNonHttpHandlerFact[], +): void { + if (facts.length === 0) springNonHttpHandlerFacts.delete(filePath); + else springNonHttpHandlerFacts.set(filePath, facts); +} + +export function getJavaSpringNonHttpHandlerFacts( + filePath: string, +): readonly JavaSpringNonHttpHandlerFact[] { + return springNonHttpHandlerFacts.get(filePath) ?? []; +} + /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */ export function collectJavaCaptureSideChannel( filePath: string, @@ -107,6 +125,7 @@ export function collectJavaCaptureSideChannel( const configConsumers = springConfigConsumers.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && @@ -114,6 +133,7 @@ export function collectJavaCaptureSideChannel( configConsumers.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + nonHttpHandlerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -126,6 +146,7 @@ export function collectJavaCaptureSideChannel( ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), }; } @@ -148,6 +169,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); + setJavaSpringNonHttpHandlerFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -168,6 +190,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setJavaSpringNonHttpHandlerFacts( + parsed.filePath, + Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], + ); setJavaPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 47c694b37..ce5ed4b93 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -39,6 +39,7 @@ import { setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, + setJavaSpringNonHttpHandlerFacts, } from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; @@ -50,6 +51,10 @@ import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, } from './spring-conditionals.js'; +import { + captureJavaSpringNonHttpHandlerFacts, + type JavaSpringNonHttpHandlerFact, +} from './spring-non-http-handlers.js'; import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; /** Declaration anchors that carry function-like arity metadata. */ @@ -139,6 +144,7 @@ export function emitJavaScopeCaptures( const springAopTypeNodeIds = new Set(); const springConditionalFacts: JavaSpringConditionalFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = []; + const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = []; const springDiClassNodeIds = new Set(); for (const m of rawMatches) { @@ -174,6 +180,9 @@ export function emitJavaScopeCaptures( springConditionalFacts.push( ...captureJavaSpringConditionalFacts(springDiClassNode, filePath), ); + springNonHttpHandlerFacts.push( + ...captureJavaSpringNonHttpHandlerFacts(springDiClassNode, filePath), + ); const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath); if (fact !== null) springDiFacts.push(fact); } @@ -392,6 +401,7 @@ export function emitJavaScopeCaptures( setJavaSpringAopFacts(filePath, springAopFacts); setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); + setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); return [ ...resolveVarTypeBindings(out), diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 1e26e2194..0410baa6c 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -34,6 +34,7 @@ import { attachJavaSpringAopMetadata } from './spring-aop.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; +import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, @@ -94,6 +95,7 @@ const javaScopeResolver: ScopeResolver = { attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); }, }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts new file mode 100644 index 000000000..259dace3a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts @@ -0,0 +1,40 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringNonHttpHandlerMetadataAttacher, + hasSpringNonHttpHandlerRelevantAnnotation, + type SpringNonHttpHandlerFact, +} from '../../frameworks/spring/non-http-handlers.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaSpringNonHttpHandlerFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js'; + +export type JavaSpringNonHttpHandlerFact = SpringNonHttpHandlerFact; + +/** Capture callable syntax while the Java class AST is already in hand. */ +export function captureJavaSpringNonHttpHandlerFacts( + classNode: SyntaxNode, + filePath: string, +): JavaSpringNonHttpHandlerFact[] { + const facts: JavaSpringNonHttpHandlerFact[] = []; + const body = classNode.childForFieldName('body'); + if (body === null) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'method_declaration') continue; + const annotations = javaSpringAnnotationFacts(member); + if (!hasSpringNonHttpHandlerRelevantAnnotation(annotations)) continue; + const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; + facts.push({ + ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }), + ownerFilePath: filePath, + ownerRange, + annotations, + }); + } + return facts; +} + +export const attachJavaSpringNonHttpHandlerMetadata = createSpringNonHttpHandlerMetadataAttacher({ + getFacts: getJavaSpringNonHttpHandlerFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index 3c211bcc0..6ea9480a8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -52,11 +52,13 @@ import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; +import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; const classAnnotations = createClassAnnotationFactStore(); const springAopFacts = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springNonHttpHandlerFacts = new Map(); /** * Plain JSON-serializable snapshot of the per-file Kotlin capture-time @@ -78,6 +80,8 @@ export interface KotlinCaptureSideChannel { readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ readonly springDiFacts?: readonly KotlinSpringDiClassFact[]; + /** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */ + readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[]; } export function clearKotlinClassAnnotationFacts(): void { @@ -85,6 +89,7 @@ export function clearKotlinClassAnnotationFacts(): void { springAopFacts.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springNonHttpHandlerFacts.clear(); } export function setKotlinSpringAopFacts( @@ -136,6 +141,20 @@ export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringD return springDiFacts.get(filePath) ?? []; } +export function setKotlinSpringNonHttpHandlerFacts( + filePath: string, + facts: readonly KotlinSpringNonHttpHandlerFact[], +): void { + if (facts.length === 0) springNonHttpHandlerFacts.delete(filePath); + else springNonHttpHandlerFacts.set(filePath, facts); +} + +export function getKotlinSpringNonHttpHandlerFacts( + filePath: string, +): readonly KotlinSpringNonHttpHandlerFact[] { + return springNonHttpHandlerFacts.get(filePath) ?? []; +} + /** * `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin. * Returns `undefined` when this file recorded no side-channel state at all, so @@ -149,6 +168,7 @@ export function collectKotlinCaptureSideChannel( const aopFacts = springAopFacts.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && @@ -156,6 +176,7 @@ export function collectKotlinCaptureSideChannel( aopFacts.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + nonHttpHandlerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -168,6 +189,7 @@ export function collectKotlinCaptureSideChannel( ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), }; } @@ -193,6 +215,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { setKotlinSpringAopFacts(parsed.filePath, []); setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); + setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -212,6 +235,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setKotlinSpringNonHttpHandlerFacts( + parsed.filePath, + Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], + ); setKotlinPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 4e063c3cf..84ec8dc4d 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -23,6 +23,7 @@ import { setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, + setKotlinSpringNonHttpHandlerFacts, } from './capture-side-channel.js'; import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; @@ -33,6 +34,10 @@ import { captureKotlinSpringConditionalFacts, type KotlinSpringConditionalFact, } from './spring-conditionals.js'; +import { + captureKotlinSpringNonHttpHandlerFacts, + type KotlinSpringNonHttpHandlerFact, +} from './spring-non-http-handlers.js'; const FUNCTION_DECL_TAGS = ['@declaration.function'] as const; @@ -99,6 +104,8 @@ export function emitKotlinScopeCaptures( const springAopTypeNodeIds = new Set(); const springConditionalFacts: KotlinSpringConditionalFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = []; + const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = []; + const springNonHttpHandlerTypeNodeIds = new Set(); const springDiClassNodeIds = new Set(); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes)); @@ -130,9 +137,17 @@ export function emitKotlinScopeCaptures( nodeIfType(groupedNodes['@scope.class'], 'object_declaration'), nodeIfType(groupedNodes['@scope.class'], 'companion_object'), ].find((node): node is SyntaxNode => node !== null); - if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { - springAopTypeNodeIds.add(springAopTypeNode.id); - springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + if (springAopTypeNode !== undefined) { + if (!springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + } + if (!springNonHttpHandlerTypeNodeIds.has(springAopTypeNode.id)) { + springNonHttpHandlerTypeNodeIds.add(springAopTypeNode.id); + springNonHttpHandlerFacts.push( + ...captureKotlinSpringNonHttpHandlerFacts(springAopTypeNode, filePath), + ); + } } const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); @@ -342,6 +357,7 @@ export function emitKotlinScopeCaptures( setKotlinSpringAopFacts(filePath, springAopFacts); setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); + setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); return out; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index af101af3c..f5bd9bb29 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -26,6 +26,7 @@ import { attachKotlinSpringAopMetadata } from './spring-aop.js'; import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; +import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; /** * Kotlin scope resolver for RFC #909 Ring 3. @@ -142,6 +143,7 @@ export const kotlinScopeResolver: ScopeResolver = { attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts new file mode 100644 index 000000000..b46132073 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts @@ -0,0 +1,52 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringNonHttpHandlerMetadataAttacher, + type SpringNonHttpHandlerAnnotationFact, + type SpringNonHttpHandlerFact, +} from '../../frameworks/spring/non-http-handlers.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringNonHttpHandlerFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { kotlinSpringAnnotationFacts } from './spring-di.js'; + +export type KotlinSpringNonHttpHandlerFact = + SpringNonHttpHandlerFact; + +/** + * Capture annotated callables conservatively. A simple-name prefilter would + * discard Kotlin aliases (for example, `EventListener as SpringEvent`) before + * the post-import resolver can map the local name back to its annotation FQN. + */ +export function captureKotlinSpringNonHttpHandlerFacts( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringNonHttpHandlerFact[] { + const facts: KotlinSpringNonHttpHandlerFact[] = []; + const body = classNode.namedChildren.find( + (child) => child.type === 'class_body' || child.type === 'enum_class_body', + ); + if (body === undefined) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'function_declaration') continue; + const annotations = kotlinSpringAnnotationFacts(member); + if (annotations.length === 0) continue; + const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; + facts.push({ + ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }), + ownerFilePath: filePath, + ownerRange, + annotations: annotations.map((annotation) => ({ + name: annotation.name, + ...(annotation.useSiteTarget === undefined + ? {} + : { useSiteTarget: annotation.useSiteTarget }), + })), + }); + } + return facts; +} + +export const attachKotlinSpringNonHttpHandlerMetadata = createSpringNonHttpHandlerMetadataAttacher({ + getFacts: getKotlinSpringNonHttpHandlerFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index d60c731fd..3e8739e4c 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -171,6 +171,7 @@ import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js'; import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, @@ -228,6 +229,7 @@ const ANALYSIS_FEATURES = [ SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 270e2c0ad..6ea3f25d7 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -533,7 +533,15 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // value above every in-flight claim at this merge (main 67, #2891's 59, #1616's // stale 2), which is the rule above: above every claim, not above origin/main. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 68; +// +// 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 / #2891). +// Java and Kotlin ParsedFiles now persist scheduled, event, messaging, and +// managed-job handler syntax. A warm cache without these facts would stamp the +// analysis feature as complete while promoting zero handlers. This PR's former +// value 59 is now part of main's ledger, main currently holds 68, and open PR +// #2972 publishes 69; 70 is the next free value above every known claim. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 70; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/Bean.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/Bean.java new file mode 100644 index 000000000..ab35a0db9 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/Bean.java @@ -0,0 +1,3 @@ +package com.example.fake; + +public @interface Bean {} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/EventListener.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/EventListener.java new file mode 100644 index 000000000..eff4c4451 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/EventListener.java @@ -0,0 +1,3 @@ +package com.example.fake; + +public @interface EventListener {} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/XxlJob.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/XxlJob.java new file mode 100644 index 000000000..ed72dad99 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/XxlJob.java @@ -0,0 +1,5 @@ +package com.example.fake; + +public @interface XxlJob { + String value(); +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ApplicationEvents.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ApplicationEvents.java new file mode 100644 index 000000000..73b52695b --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ApplicationEvents.java @@ -0,0 +1,16 @@ +package com.example.handlers; + +import org.springframework.context.event.EventListener; + +public class ApplicationEvents { + @EventListener + public void onOrderCreated(Object event) { + validateEvent(); + } + + private void validateEvent() { + projectOrder(); + } + + private void projectOrder() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/CustomAnnotationHandler.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/CustomAnnotationHandler.java new file mode 100644 index 000000000..b2fff68f5 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/CustomAnnotationHandler.java @@ -0,0 +1,22 @@ +package com.example.handlers; + +import com.example.fake.EventListener; +import com.example.fake.XxlJob; + +public class CustomAnnotationHandler { + @EventListener + public void fakeEventHandler() { + fakeStep(); + } + + @XxlJob("fakeJob") + public void fakeXxlJobHandler() { + fakeStep(); + } + + private void fakeStep() { + fakeTerminal(); + } + + private void fakeTerminal() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/MessageConsumers.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/MessageConsumers.java new file mode 100644 index 000000000..2e37fe86d --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/MessageConsumers.java @@ -0,0 +1,24 @@ +package com.example.handlers; + +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.messaging.handler.annotation.MessageMapping; +import jakarta.ws.rs.Path; + +public class MessageConsumers { + @KafkaListener(topics = "orders") + public void consumeOrder(String payload) { + decodeOrder(); + } + + @Path("/websocket-order") + @MessageMapping("/orders") + public void consumeOverWebSocket(String payload) { + decodeOrder(); + } + + private void decodeOrder() { + dispatchOrder(); + } + + private void dispatchOrder() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ScheduledJobs.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ScheduledJobs.java new file mode 100644 index 000000000..57b32e6e2 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ScheduledJobs.java @@ -0,0 +1,23 @@ +package com.example.handlers; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.context.event.EventListener; + +public class ScheduledJobs { + @Scheduled(fixedDelayString = "PT1M") + public void refreshProjection() { + loadPendingChanges(); + } + + @Scheduled(fixedDelayString = "PT5M") + @EventListener + public void refreshAfterEvent(Object event) { + loadPendingChanges(); + } + + private void loadPendingChanges() { + storeProjection(); + } + + private void storeProjection() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ServiceActivators.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ServiceActivators.java new file mode 100644 index 000000000..b911c6074 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/ServiceActivators.java @@ -0,0 +1,28 @@ +package com.example.handlers; + +import com.example.fake.Bean; +import org.springframework.integration.annotation.ServiceActivator; + +public class ServiceActivators { + @org.springframework.context.annotation.Bean + @ServiceActivator(inputChannel = "errorChannel") + public Object beanFactoryServiceActivator() { + return buildErrorHandler(); + } + + @Bean + @ServiceActivator(inputChannel = "orders") + public void fakeBeanServiceActivator(String payload) { + dispatchMessage(); + } + + private Object buildErrorHandler() { + return new Object(); + } + + private void dispatchMessage() { + persistMessage(); + } + + private void persistMessage() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/XxlJobs.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/XxlJobs.java new file mode 100644 index 000000000..0af4fe796 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/XxlJobs.java @@ -0,0 +1,23 @@ +package com.example.handlers; + +import com.xxl.job.core.handler.annotation.XxlJob; + +public class XxlJobs { + private static final String JOB_NAME = "constantJobHandler"; + + @XxlJob("literalJobHandler") + public void runLiteralJob() { + executeJob(); + } + + @XxlJob(JOB_NAME) + public void runConstantJob() { + executeJob(); + } + + private void executeJob() { + recordCompletion(); + } + + private void recordCompletion() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedEvents.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedEvents.kt new file mode 100644 index 000000000..535e110ca --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedEvents.kt @@ -0,0 +1,16 @@ +package com.example.handlers + +import org.springframework.context.event.EventListener as SpringEvent + +class AliasedEvents { + @SpringEvent + fun onKotlinEvent(event: Any) { + normalizeEvent() + } + + private fun normalizeEvent() { + persistEvent() + } + + private fun persistEvent() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedXxlJob.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedXxlJob.kt new file mode 100644 index 000000000..9ee803a77 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/AliasedXxlJob.kt @@ -0,0 +1,20 @@ +package com.example.handlers + +import com.xxl.job.core.handler.annotation.XxlJob as ManagedJob + +class AliasedXxlJob { + @ManagedJob(JOB_NAME) + fun runKotlinJob() { + executeJob() + } + + private fun executeJob() { + recordCompletion() + } + + private fun recordCompletion() {} + + companion object { + const val JOB_NAME = "kotlinJobHandler" + } +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/ListenerContract.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/ListenerContract.kt new file mode 100644 index 000000000..fc98d3177 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/ListenerContract.kt @@ -0,0 +1,12 @@ +package com.example.handlers + +import org.springframework.context.event.EventListener as SpringEvent + +interface ListenerContract { + @SpringEvent + fun interfaceEvent(event: Any) { + interfaceStep() + } + + fun interfaceStep() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/SingletonHandlers.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/SingletonHandlers.kt new file mode 100644 index 000000000..43af1bb52 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/SingletonHandlers.kt @@ -0,0 +1,48 @@ +package com.example.handlers + +import org.springframework.context.event.EventListener +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.scheduling.annotation.Scheduled + +object CacheWarmer { + @Scheduled(fixedRate = 60_000) + fun warmSingleton() { + refreshCache() + } + + private fun refreshCache() { + persistCache() + } + + private fun persistCache() {} +} + +class CompanionEventHandlers { + companion object { + @EventListener + fun onCompanionEvent(event: Any) { + recordCompanionEvent() + } + + private fun recordCompanionEvent() { + persistCompanionEvent() + } + + private fun persistCompanionEvent() {} + } +} + +enum class EnumMessageHandlers { + CREATED; + + @KafkaListener(topics = ["enum-orders"]) + fun consumeEnumMessage(payload: String) { + recordEnumMessage() + } + + private fun recordEnumMessage() { + persistEnumMessage() + } + + private fun persistEnumMessage() {} +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/UseSiteTarget.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/UseSiteTarget.kt new file mode 100644 index 000000000..9e472f255 --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/UseSiteTarget.kt @@ -0,0 +1,12 @@ +package com.example.handlers + +import org.springframework.context.event.EventListener + +class UseSiteTarget { + @receiver:EventListener + fun String.targetedReceiverIsNotAHandler() { + useSiteStep() + } + + private fun useSiteStep() {} +} diff --git a/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts b/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts new file mode 100644 index 000000000..6e239675e --- /dev/null +++ b/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-non-http-handler-app'); + +describe('Spring non-HTTP handler entry points (#2417)', () => { + let result: PipelineResult; + let methods: GraphNode[]; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, {}); + // Kotlin enum members currently use the graph's Function label; they are + // still class-owned callables and participate in process detection. + methods = [...result.graph.iterNodes()].filter( + (node) => node.label === 'Method' || node.label === 'Function', + ); + }, 60_000); + + function methodNamed(name: string, fileSuffix: string): GraphNode { + const matches = methods.filter( + (method) => + method.properties.name === name && String(method.properties.filePath).endsWith(fileSuffix), + ); + if (matches.length !== 1) { + throw new Error( + `${fileSuffix}#${name} should resolve to one callable, found ${matches.length}`, + ); + } + return matches[0]; + } + + it.each([ + ['refreshProjection', 'ScheduledJobs.java', 'spring-scheduled-handler'], + ['onOrderCreated', 'ApplicationEvents.java', 'spring-event-handler'], + ['consumeOrder', 'MessageConsumers.java', 'spring-message-handler'], + ['onKotlinEvent', 'AliasedEvents.kt', 'spring-event-handler'], + ['runLiteralJob', 'XxlJobs.java', 'xxl-job-handler'], + ['runConstantJob', 'XxlJobs.java', 'xxl-job-handler'], + ['runKotlinJob', 'AliasedXxlJob.kt', 'xxl-job-handler'], + ['refreshAfterEvent', 'ScheduledJobs.java', 'spring-non-http-handler'], + ['warmSingleton', 'SingletonHandlers.kt', 'spring-scheduled-handler'], + ['onCompanionEvent', 'SingletonHandlers.kt', 'spring-event-handler'], + ['consumeEnumMessage', 'SingletonHandlers.kt', 'spring-message-handler'], + ['fakeBeanServiceActivator', 'ServiceActivators.java', 'spring-message-handler'], + ])('marks %s in %s as a framework-managed process entry point', (methodName, file, reason) => { + const method = methodNamed(methodName, file); + expect(method.properties.astFrameworkMultiplier).toBe(3); + expect(method.properties.astFrameworkReason).toBe(reason); + }); + + it.each([ + ['fakeEventHandler', 'CustomAnnotationHandler.java'], + ['fakeXxlJobHandler', 'CustomAnnotationHandler.java'], + ])( + 'fails closed for the same-name annotation on %s in %s imported from an unrelated package', + (methodName, file) => { + const method = methodNamed(methodName, file); + expect(method.properties.astFrameworkMultiplier).toBeUndefined(); + expect(method.properties.astFrameworkReason).toBeUndefined(); + }, + ); + + it.each([ + ['interfaceEvent', 'ListenerContract.kt'], + ['targetedReceiverIsNotAHandler', 'UseSiteTarget.kt'], + ['beanFactoryServiceActivator', 'ServiceActivators.java'], + ])( + 'does not promote excluded callable %s in %s into a provider entry point', + (methodName, file) => { + const method = methodNamed(methodName, file); + expect(method.properties.astFrameworkMultiplier).toBeUndefined(); + expect(method.properties.astFrameworkReason).toBeUndefined(); + }, + ); + + it('preserves an existing same-strength framework reason', () => { + const method = methodNamed('consumeOverWebSocket', 'MessageConsumers.java'); + expect(method.properties.astFrameworkMultiplier).toBe(3); + expect(method.properties.astFrameworkReason).toBe('jaxrs-annotation'); + }); + + it('does not model non-HTTP framework handlers as HTTP routes', () => { + const routes: GraphNode[] = []; + result.graph.forEachNode((node) => { + if (node.label === 'Route') routes.push(node); + }); + expect(routes).toEqual([]); + }); + + it.each([ + ['refreshProjection', 'ScheduledJobs.java'], + ['onOrderCreated', 'ApplicationEvents.java'], + ['consumeOrder', 'MessageConsumers.java'], + ['onKotlinEvent', 'AliasedEvents.kt'], + ['runLiteralJob', 'XxlJobs.java'], + ['runConstantJob', 'XxlJobs.java'], + ['runKotlinJob', 'AliasedXxlJob.kt'], + ['refreshAfterEvent', 'ScheduledJobs.java'], + ['warmSingleton', 'SingletonHandlers.kt'], + ['onCompanionEvent', 'SingletonHandlers.kt'], + ['consumeEnumMessage', 'SingletonHandlers.kt'], + ['fakeBeanServiceActivator', 'ServiceActivators.java'], + ])('starts process/context output from %s in %s', (methodName, file) => { + const method = methodNamed(methodName, file); + const process = result.processResult?.processes.find( + (candidate) => candidate.entryPointId === method.id, + ); + if (process === undefined) throw new Error(`${methodName} should start a detected process`); + + const entryStep = [...result.graph.iterRelationships()].find( + (relationship) => + relationship.type === 'STEP_IN_PROCESS' && + relationship.sourceId === method.id && + relationship.targetId === process.id && + relationship.step === 1, + ); + expect(entryStep, `${methodName} should be step 1 of its process`).toBeDefined(); + }); +}); + +describe('Spring non-HTTP handler durable warm parse cache (#2417)', () => { + it('replays identical Java/Kotlin handler metadata without spawning workers', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-non-http-warm-')); + const storage = path.join(temp, 'storage'); + try { + const coldCache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storage, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(FIXTURE, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: coldCache, + }); + expect(cold.usedWorkerPool).toBe(true); + + pruneCache(coldCache, coldCache.usedKeys); + const savedKeys = await saveParseCache(storage, coldCache); + expect(savedKeys.length).toBeGreaterThan(0); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warmCache = await loadParseCache(storage); + expect(warmCache.onDiskKeys).toEqual(new Set(savedKeys)); + const warm = await runPipelineFromRepo(FIXTURE, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + parseCache: warmCache, + }); + expect(warm.usedWorkerPool).toBe(false); + + const project = (pipeline: PipelineResult) => + [...pipeline.graph.iterNodes()] + .filter( + (node) => + (node.label === 'Method' || node.label === 'Function') && + node.properties.astFrameworkMultiplier === 3, + ) + .map((node) => ({ + file: path.basename(String(node.properties.filePath)), + name: node.properties.name, + multiplier: node.properties.astFrameworkMultiplier, + reason: node.properties.astFrameworkReason, + })) + .sort((left, right) => + `${left.file}#${left.name}`.localeCompare(`${right.file}#${right.name}`), + ); + + const coldMetadata = project(cold); + expect(project(warm)).toEqual(coldMetadata); + expect(coldMetadata).toEqual( + expect.arrayContaining([ + { + file: 'ScheduledJobs.java', + name: 'refreshProjection', + multiplier: 3, + reason: 'spring-scheduled-handler', + }, + { + file: 'AliasedEvents.kt', + name: 'onKotlinEvent', + multiplier: 3, + reason: 'spring-event-handler', + }, + { + file: 'XxlJobs.java', + name: 'runLiteralJob', + multiplier: 3, + reason: 'xxl-job-handler', + }, + { + file: 'AliasedXxlJob.kt', + name: 'runKotlinJob', + multiplier: 3, + reason: 'xxl-job-handler', + }, + { + file: 'SingletonHandlers.kt', + name: 'warmSingleton', + multiplier: 3, + reason: 'spring-scheduled-handler', + }, + ]), + ); + expect(coldMetadata.some((method) => method.name === 'beanFactoryServiceActivator')).toBe( + false, + ); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 43e36d5de..5bc70eb0f 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -9,6 +9,7 @@ import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, @@ -21,6 +22,7 @@ const FEATURES = [ SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, @@ -39,12 +41,14 @@ describe('analysis feature versions', () => { 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 1, + 'spring.non-http-handlers': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, + 'spring.non-http-handlers': 1, }); expect( resolveAnalysisFeatureVersions(FEATURES, [ diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 964b9420d..ff315faa6 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -47,6 +47,7 @@ import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { decodeSpringAopReason, @@ -603,6 +604,7 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, + [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, }); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id)); @@ -621,6 +623,7 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, + [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, }); } finally { await repo.cleanup(); @@ -690,6 +693,7 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, + [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, }); } finally { await repo.cleanup(); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 4373ebf2c..0dfe29599 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -218,15 +218,18 @@ describe('PARSE_CACHE_VERSION', () => { // the pre-fix fan-out. This branch staged 64 above the claims live at the // time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the // next free value above every claim at merge — the rule, re-applied. - it('pins SCHEMA_BUMP to 68 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(68); + // Moved 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 / + // #2891). Main holds 68 and open PR #2972 publishes 69, so 70 is the next + // free value above every known claim at this merge. + it('pins SCHEMA_BUMP to 70 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(70); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. - // Every nearby historical value is rejected: origin/main advanced through - // 67, and this branch previously published 64. Pinning 68 and rejecting all - // prior values makes an accidental conflict resolution loud. - for (const taken of [60, 61, 62, 63, 64, 65, 66, 67]) { + // Every nearby historical or in-flight value is rejected: this branch + // previously published 59, origin/main advanced through 68, and #2972 + // publishes 69. Rejecting all of them makes a bad conflict resolution loud. + for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } }); diff --git a/gitnexus/test/unit/spring-bean-extractor.test.ts b/gitnexus/test/unit/spring-bean-extractor.test.ts index 2f2cc1dbc..5d37b137f 100644 --- a/gitnexus/test/unit/spring-bean-extractor.test.ts +++ b/gitnexus/test/unit/spring-bean-extractor.test.ts @@ -111,6 +111,14 @@ function captureKotlinSpringDiFacts( return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? []; } +function collectKotlinSpringNonHttpHandlerFactsFromSource( + code: string, +): NonNullable { + const filePath = 'src/Test.kt'; + emitKotlinScopeCaptures(code, filePath); + return collectKotlinCaptureSideChannel(filePath)?.springNonHttpHandlerFacts ?? []; +} + describe('Kotlin class annotation capture', () => { it('captures supported class forms and excludes non-candidate declarations', () => { const facts = captureKotlinClassAnnotations(` @@ -146,6 +154,48 @@ describe('Kotlin class annotation capture', () => { }); }); +describe('Kotlin Spring non-HTTP handler syntax capture', () => { + it('captures class-like owners while persisting only annotation resolution fields', () => { + const facts = collectKotlinSpringNonHttpHandlerFactsFromSource(` + class RegularHandlers { + @Scheduled fun regularHandler() {} + @receiver:EventListener fun String.targetedHandler() {} + } + + object SingletonHandlers { + @KafkaListener(topics = ["orders"]) + fun singletonHandler() {} + } + + class CompanionHolder { + companion object { + @TransactionalEventListener fun companionHandler(event: Any) {} + } + } + + enum class EnumHandlers { + READY; + @XxlJob("enum-handler") fun enumHandler() {} + } + `); + + const annotations = facts + .flatMap((fact) => fact.annotations) + .sort((left, right) => left.name.localeCompare(right.name)); + expect(annotations).toEqual([ + { name: 'EventListener', useSiteTarget: 'receiver' }, + { name: 'KafkaListener' }, + { name: 'Scheduled' }, + { name: 'TransactionalEventListener' }, + { name: 'XxlJob' }, + ]); + for (const annotation of annotations) { + expect(annotation).not.toHaveProperty('text'); + expect(annotation).not.toHaveProperty('line'); + } + }); +}); + describe('Kotlin Spring injection syntax capture', () => { it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => { const facts = captureKotlinSpringDiFacts(` diff --git a/gitnexus/test/unit/spring-non-http-handlers.test.ts b/gitnexus/test/unit/spring-non-http-handlers.test.ts new file mode 100644 index 000000000..2848647df --- /dev/null +++ b/gitnexus/test/unit/spring-non-http-handlers.test.ts @@ -0,0 +1,64 @@ +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; +import { createSpringNonHttpHandlerMetadataAttacher } from '../../src/core/ingestion/frameworks/spring/non-http-handlers.js'; +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { GraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; + +const FILE_PATH = 'src/Test.kt'; +const OWNER_SCOPE_ID = 'scope:test' as ScopeId; +const PARSED_FILE = { + filePath: FILE_PATH, + parsedImports: [], +} as unknown as ParsedFile; + +function minimalIndexes(): ScopeResolutionIndexes { + return { + defs: { byId: new Map() }, + scopeTree: { getScope: () => undefined }, + methodDispatch: { mroFor: () => [] }, + } as unknown as ScopeResolutionIndexes; +} + +describe('Spring non-HTTP handler metadata attachment', () => { + it.each([ + ['no facts', []], + [ + 'only irrelevant annotations', + [ + { + ownerScopeId: OWNER_SCOPE_ID, + ownerFilePath: FILE_PATH, + ownerRange: { startLine: 1, startCol: 0, endLine: 1, endCol: 24 }, + annotations: [{ name: 'Override' }], + }, + ], + ], + ])('does not scan the graph when a repository has %s', (_case, facts) => { + const iterNodes = vi.fn(() => { + throw new Error('iterNodes should remain lazy'); + }); + const iterRelationshipsByType = vi.fn(() => { + throw new Error('HAS_METHOD should remain lazy'); + }); + const getNode = vi.fn(() => { + throw new Error('getNode should remain lazy'); + }); + const graph = { + iterNodes, + iterRelationshipsByType, + getNode, + } as unknown as KnowledgeGraph; + const attach = createSpringNonHttpHandlerMetadataAttacher({ + getFacts: () => facts, + isPackageVisibilityIncomplete: () => false, + }); + + expect(() => + attach(graph, [PARSED_FILE], {} as GraphNodeLookup, minimalIndexes()), + ).not.toThrow(); + expect(iterNodes).not.toHaveBeenCalled(); + expect(iterRelationshipsByType).not.toHaveBeenCalled(); + expect(getNode).not.toHaveBeenCalled(); + }); +});