feat(spring): detect non-HTTP handler entry points (#2891)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
MyShining 2026-08-16 22:16:21 +08:00 committed by GitHub
parent dac33d8056
commit fe3d7e56be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1035 additions and 10 deletions

View file

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

View file

@ -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<Annotation>[];
isPackageVisibilityIncomplete(filePath: string): boolean;
}
const SPRING_SERVICE_ACTIVATOR_ANNOTATION =
'org.springframework.integration.annotation.ServiceActivator';
const HANDLER_ANNOTATIONS = new Map<string, SpringNonHttpHandlerKind>([
['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<SpringNonHttpHandlerAnnotationFact, 'name'>[],
): boolean {
return annotations.some((annotation) =>
CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)),
);
}
function exactCallableOwnersByRange(graph: KnowledgeGraph): ReadonlyMap<string, GraphNode | null> {
const owners = new Map<string, GraphNode | null>();
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<string, GraphNode | null>,
): 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<SpringNonHttpHandlerKind>): 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<Annotation>) {
return (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const factsByFile = new Map<string, readonly SpringNonHttpHandlerFact<Annotation>[]>();
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<string, GraphNode | null> | undefined;
const getExactOwnerByRange = (): ReadonlyMap<string, GraphNode | null> =>
(exactOwnerByRange ??= exactCallableOwnersByRange(graph));
let classIdByMethod: ReadonlyMap<string, string> | undefined;
const ownerClassLabel = (methodId: string): GraphNode['label'] | undefined => {
if (classIdByMethod === undefined) {
const owners = new Map<string, string>();
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<string, string | undefined>();
for (const fact of facts) {
const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId);
const resolvedFactAnnotations = new Set<string>();
for (const annotation of fact.annotations) {
if (annotation.useSiteTarget !== undefined) continue;
const enclosingScope = ownerScope?.parent ?? null;
const cacheKey = `${enclosingScope ?? '<root>'}\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<SpringNonHttpHandlerKind>();
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);
}
}
}
};
}

View file

@ -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<string, readonly JavaSpringAopFact[]>();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly JavaSpringNonHttpHandlerFact[]>();
/** 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,

View file

@ -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<number>();
const springConditionalFacts: JavaSpringConditionalFact[] = [];
const springDiFacts: JavaSpringDiClassFact[] = [];
const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = [];
const springDiClassNodeIds = new Set<number>();
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),

View file

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

View file

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

View file

@ -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<string, readonly KotlinSpringAopFact[]>();
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly KotlinSpringNonHttpHandlerFact[]>();
/**
* 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,

View file

@ -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<number>();
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
const springDiFacts: KotlinSpringDiClassFact[] = [];
const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = [];
const springNonHttpHandlerTypeNodeIds = new Set<number>();
const springDiClassNodeIds = new Set<number>();
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
@ -130,10 +137,18 @@ 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)) {
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');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
@ -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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
package com.example.fake;
public @interface Bean {}

View file

@ -0,0 +1,3 @@
package com.example.fake;
public @interface EventListener {}

View file

@ -0,0 +1,5 @@
package com.example.fake;
public @interface XxlJob {
String value();
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

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

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

@ -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() {}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -111,6 +111,14 @@ function captureKotlinSpringDiFacts(
return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
}
function collectKotlinSpringNonHttpHandlerFactsFromSource(
code: string,
): NonNullable<KotlinCaptureSideChannel['springNonHttpHandlerFacts']> {
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(`

View file

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