mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
dac33d8056
commit
fe3d7e56be
32 changed files with 1035 additions and 10 deletions
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/Bean.java
vendored
Normal file
3
gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/fake/Bean.java
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package com.example.fake;
|
||||
|
||||
public @interface Bean {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.example.fake;
|
||||
|
||||
public @interface EventListener {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.example.fake;
|
||||
|
||||
public @interface XxlJob {
|
||||
String value();
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
64
gitnexus/test/unit/spring-non-http-handlers.test.ts
Normal file
64
gitnexus/test/unit/spring-non-http-handlers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue