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,
|
version: 1,
|
||||||
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
|
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 { JavaSpringAopFact } from './spring-aop.js';
|
||||||
import type { JavaSpringConditionalFact } from './spring-conditionals.js';
|
import type { JavaSpringConditionalFact } from './spring-conditionals.js';
|
||||||
import type { JavaSpringDiClassFact } from './spring-di.js';
|
import type { JavaSpringDiClassFact } from './spring-di.js';
|
||||||
|
import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
|
||||||
|
|
||||||
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ export interface JavaCaptureSideChannel {
|
||||||
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
||||||
readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
|
readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
|
||||||
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
||||||
|
readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const classAnnotations = createClassAnnotationFactStore();
|
const classAnnotations = createClassAnnotationFactStore();
|
||||||
|
|
@ -31,6 +33,7 @@ const springAopFacts = new Map<string, readonly JavaSpringAopFact[]>();
|
||||||
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||||
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
|
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
|
||||||
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
|
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. */
|
/** Clear facts retained by a prior workspace pass in a long-lived process. */
|
||||||
export function clearJavaClassAnnotationFacts(): void {
|
export function clearJavaClassAnnotationFacts(): void {
|
||||||
|
|
@ -39,6 +42,7 @@ export function clearJavaClassAnnotationFacts(): void {
|
||||||
springConfigConsumers.clear();
|
springConfigConsumers.clear();
|
||||||
springConditionalFacts.clear();
|
springConditionalFacts.clear();
|
||||||
springDiFacts.clear();
|
springDiFacts.clear();
|
||||||
|
springNonHttpHandlerFacts.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void {
|
export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void {
|
||||||
|
|
@ -98,6 +102,20 @@ export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiCla
|
||||||
return springDiFacts.get(filePath) ?? [];
|
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. */
|
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
|
||||||
export function collectJavaCaptureSideChannel(
|
export function collectJavaCaptureSideChannel(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|
@ -107,6 +125,7 @@ export function collectJavaCaptureSideChannel(
|
||||||
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
||||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||||
|
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||||
const packageFact = getJavaPackageFact(filePath);
|
const packageFact = getJavaPackageFact(filePath);
|
||||||
if (
|
if (
|
||||||
facts.length === 0 &&
|
facts.length === 0 &&
|
||||||
|
|
@ -114,6 +133,7 @@ export function collectJavaCaptureSideChannel(
|
||||||
configConsumers.length === 0 &&
|
configConsumers.length === 0 &&
|
||||||
conditionFacts.length === 0 &&
|
conditionFacts.length === 0 &&
|
||||||
diFacts.length === 0 &&
|
diFacts.length === 0 &&
|
||||||
|
nonHttpHandlerFacts.length === 0 &&
|
||||||
packageFact === undefined
|
packageFact === undefined
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -126,6 +146,7 @@ export function collectJavaCaptureSideChannel(
|
||||||
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
||||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||||
|
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +169,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
||||||
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
||||||
setJavaSpringConditionalFacts(parsed.filePath, []);
|
setJavaSpringConditionalFacts(parsed.filePath, []);
|
||||||
setJavaSpringDiFacts(parsed.filePath, []);
|
setJavaSpringDiFacts(parsed.filePath, []);
|
||||||
|
setJavaSpringNonHttpHandlerFacts(parsed.filePath, []);
|
||||||
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +190,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
||||||
parsed.filePath,
|
parsed.filePath,
|
||||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||||
);
|
);
|
||||||
|
setJavaSpringNonHttpHandlerFacts(
|
||||||
|
parsed.filePath,
|
||||||
|
Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [],
|
||||||
|
);
|
||||||
setJavaPackageFact(
|
setJavaPackageFact(
|
||||||
parsed.filePath,
|
parsed.filePath,
|
||||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
setJavaSpringConfigConsumerFacts,
|
setJavaSpringConfigConsumerFacts,
|
||||||
setJavaSpringConditionalFacts,
|
setJavaSpringConditionalFacts,
|
||||||
setJavaSpringDiFacts,
|
setJavaSpringDiFacts,
|
||||||
|
setJavaSpringNonHttpHandlerFacts,
|
||||||
} from './capture-side-channel.js';
|
} from './capture-side-channel.js';
|
||||||
import { captureJavaPackageFact } from './package-facts.js';
|
import { captureJavaPackageFact } from './package-facts.js';
|
||||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||||
|
|
@ -50,6 +51,10 @@ import {
|
||||||
captureJavaSpringConditionalFacts,
|
captureJavaSpringConditionalFacts,
|
||||||
type JavaSpringConditionalFact,
|
type JavaSpringConditionalFact,
|
||||||
} from './spring-conditionals.js';
|
} from './spring-conditionals.js';
|
||||||
|
import {
|
||||||
|
captureJavaSpringNonHttpHandlerFacts,
|
||||||
|
type JavaSpringNonHttpHandlerFact,
|
||||||
|
} from './spring-non-http-handlers.js';
|
||||||
import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js';
|
import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js';
|
||||||
|
|
||||||
/** Declaration anchors that carry function-like arity metadata. */
|
/** Declaration anchors that carry function-like arity metadata. */
|
||||||
|
|
@ -139,6 +144,7 @@ export function emitJavaScopeCaptures(
|
||||||
const springAopTypeNodeIds = new Set<number>();
|
const springAopTypeNodeIds = new Set<number>();
|
||||||
const springConditionalFacts: JavaSpringConditionalFact[] = [];
|
const springConditionalFacts: JavaSpringConditionalFact[] = [];
|
||||||
const springDiFacts: JavaSpringDiClassFact[] = [];
|
const springDiFacts: JavaSpringDiClassFact[] = [];
|
||||||
|
const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = [];
|
||||||
const springDiClassNodeIds = new Set<number>();
|
const springDiClassNodeIds = new Set<number>();
|
||||||
|
|
||||||
for (const m of rawMatches) {
|
for (const m of rawMatches) {
|
||||||
|
|
@ -174,6 +180,9 @@ export function emitJavaScopeCaptures(
|
||||||
springConditionalFacts.push(
|
springConditionalFacts.push(
|
||||||
...captureJavaSpringConditionalFacts(springDiClassNode, filePath),
|
...captureJavaSpringConditionalFacts(springDiClassNode, filePath),
|
||||||
);
|
);
|
||||||
|
springNonHttpHandlerFacts.push(
|
||||||
|
...captureJavaSpringNonHttpHandlerFacts(springDiClassNode, filePath),
|
||||||
|
);
|
||||||
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
|
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
|
||||||
if (fact !== null) springDiFacts.push(fact);
|
if (fact !== null) springDiFacts.push(fact);
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +401,7 @@ export function emitJavaScopeCaptures(
|
||||||
setJavaSpringAopFacts(filePath, springAopFacts);
|
setJavaSpringAopFacts(filePath, springAopFacts);
|
||||||
setJavaSpringConditionalFacts(filePath, springConditionalFacts);
|
setJavaSpringConditionalFacts(filePath, springConditionalFacts);
|
||||||
setJavaSpringDiFacts(filePath, springDiFacts);
|
setJavaSpringDiFacts(filePath, springDiFacts);
|
||||||
|
setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...resolveVarTypeBindings(out),
|
...resolveVarTypeBindings(out),
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { attachJavaSpringAopMetadata } from './spring-aop.js';
|
||||||
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
||||||
import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
|
import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
|
||||||
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
||||||
|
import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
|
||||||
import {
|
import {
|
||||||
applyJavaCaptureSideChannel,
|
applyJavaCaptureSideChannel,
|
||||||
clearJavaClassAnnotationFacts,
|
clearJavaClassAnnotationFacts,
|
||||||
|
|
@ -94,6 +95,7 @@ const javaScopeResolver: ScopeResolver = {
|
||||||
attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes);
|
attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
|
attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
|
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 { KotlinSpringAopFact } from './spring-aop.js';
|
||||||
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
|
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
|
||||||
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
||||||
|
import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
|
||||||
|
|
||||||
const classAnnotations = createClassAnnotationFactStore();
|
const classAnnotations = createClassAnnotationFactStore();
|
||||||
const springAopFacts = new Map<string, readonly KotlinSpringAopFact[]>();
|
const springAopFacts = new Map<string, readonly KotlinSpringAopFact[]>();
|
||||||
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
|
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
|
||||||
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
|
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
|
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
|
||||||
|
|
@ -78,6 +80,8 @@ export interface KotlinCaptureSideChannel {
|
||||||
readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[];
|
readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[];
|
||||||
/** Constructor, property, and method injection syntax captured per class. */
|
/** Constructor, property, and method injection syntax captured per class. */
|
||||||
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
||||||
|
/** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
|
||||||
|
readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearKotlinClassAnnotationFacts(): void {
|
export function clearKotlinClassAnnotationFacts(): void {
|
||||||
|
|
@ -85,6 +89,7 @@ export function clearKotlinClassAnnotationFacts(): void {
|
||||||
springAopFacts.clear();
|
springAopFacts.clear();
|
||||||
springConditionalFacts.clear();
|
springConditionalFacts.clear();
|
||||||
springDiFacts.clear();
|
springDiFacts.clear();
|
||||||
|
springNonHttpHandlerFacts.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setKotlinSpringAopFacts(
|
export function setKotlinSpringAopFacts(
|
||||||
|
|
@ -136,6 +141,20 @@ export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringD
|
||||||
return springDiFacts.get(filePath) ?? [];
|
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.
|
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
||||||
* Returns `undefined` when this file recorded no side-channel state at all, so
|
* 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 aopFacts = springAopFacts.get(filePath) ?? [];
|
||||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||||
|
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
|
||||||
const packageFact = getKotlinPackageFact(filePath);
|
const packageFact = getKotlinPackageFact(filePath);
|
||||||
if (
|
if (
|
||||||
companionScopes.length === 0 &&
|
companionScopes.length === 0 &&
|
||||||
|
|
@ -156,6 +176,7 @@ export function collectKotlinCaptureSideChannel(
|
||||||
aopFacts.length === 0 &&
|
aopFacts.length === 0 &&
|
||||||
conditionFacts.length === 0 &&
|
conditionFacts.length === 0 &&
|
||||||
diFacts.length === 0 &&
|
diFacts.length === 0 &&
|
||||||
|
nonHttpHandlerFacts.length === 0 &&
|
||||||
packageFact === undefined
|
packageFact === undefined
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -168,6 +189,7 @@ export function collectKotlinCaptureSideChannel(
|
||||||
...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}),
|
...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}),
|
||||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||||
|
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +215,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
||||||
setKotlinSpringAopFacts(parsed.filePath, []);
|
setKotlinSpringAopFacts(parsed.filePath, []);
|
||||||
setKotlinSpringConditionalFacts(parsed.filePath, []);
|
setKotlinSpringConditionalFacts(parsed.filePath, []);
|
||||||
setKotlinSpringDiFacts(parsed.filePath, []);
|
setKotlinSpringDiFacts(parsed.filePath, []);
|
||||||
|
setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
|
||||||
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +235,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
||||||
parsed.filePath,
|
parsed.filePath,
|
||||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||||
);
|
);
|
||||||
|
setKotlinSpringNonHttpHandlerFacts(
|
||||||
|
parsed.filePath,
|
||||||
|
Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [],
|
||||||
|
);
|
||||||
setKotlinPackageFact(
|
setKotlinPackageFact(
|
||||||
parsed.filePath,
|
parsed.filePath,
|
||||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
setKotlinSpringAopFacts,
|
setKotlinSpringAopFacts,
|
||||||
setKotlinSpringConditionalFacts,
|
setKotlinSpringConditionalFacts,
|
||||||
setKotlinSpringDiFacts,
|
setKotlinSpringDiFacts,
|
||||||
|
setKotlinSpringNonHttpHandlerFacts,
|
||||||
} from './capture-side-channel.js';
|
} from './capture-side-channel.js';
|
||||||
import { captureKotlinPackageFact } from './package-facts.js';
|
import { captureKotlinPackageFact } from './package-facts.js';
|
||||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||||
|
|
@ -33,6 +34,10 @@ import {
|
||||||
captureKotlinSpringConditionalFacts,
|
captureKotlinSpringConditionalFacts,
|
||||||
type KotlinSpringConditionalFact,
|
type KotlinSpringConditionalFact,
|
||||||
} from './spring-conditionals.js';
|
} from './spring-conditionals.js';
|
||||||
|
import {
|
||||||
|
captureKotlinSpringNonHttpHandlerFacts,
|
||||||
|
type KotlinSpringNonHttpHandlerFact,
|
||||||
|
} from './spring-non-http-handlers.js';
|
||||||
|
|
||||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||||
|
|
||||||
|
|
@ -99,6 +104,8 @@ export function emitKotlinScopeCaptures(
|
||||||
const springAopTypeNodeIds = new Set<number>();
|
const springAopTypeNodeIds = new Set<number>();
|
||||||
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
|
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
|
||||||
const springDiFacts: KotlinSpringDiClassFact[] = [];
|
const springDiFacts: KotlinSpringDiClassFact[] = [];
|
||||||
|
const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = [];
|
||||||
|
const springNonHttpHandlerTypeNodeIds = new Set<number>();
|
||||||
const springDiClassNodeIds = new Set<number>();
|
const springDiClassNodeIds = new Set<number>();
|
||||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||||
|
|
@ -130,9 +137,17 @@ export function emitKotlinScopeCaptures(
|
||||||
nodeIfType(groupedNodes['@scope.class'], 'object_declaration'),
|
nodeIfType(groupedNodes['@scope.class'], 'object_declaration'),
|
||||||
nodeIfType(groupedNodes['@scope.class'], 'companion_object'),
|
nodeIfType(groupedNodes['@scope.class'], 'companion_object'),
|
||||||
].find((node): node is SyntaxNode => node !== null);
|
].find((node): node is SyntaxNode => node !== null);
|
||||||
if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) {
|
if (springAopTypeNode !== undefined) {
|
||||||
springAopTypeNodeIds.add(springAopTypeNode.id);
|
if (!springAopTypeNodeIds.has(springAopTypeNode.id)) {
|
||||||
springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath));
|
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');
|
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
|
||||||
|
|
@ -342,6 +357,7 @@ export function emitKotlinScopeCaptures(
|
||||||
setKotlinSpringAopFacts(filePath, springAopFacts);
|
setKotlinSpringAopFacts(filePath, springAopFacts);
|
||||||
setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
|
setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
|
||||||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||||
|
setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
||||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { attachKotlinSpringAopMetadata } from './spring-aop.js';
|
||||||
import { clearKotlinPackageFacts } from './package-facts.js';
|
import { clearKotlinPackageFacts } from './package-facts.js';
|
||||||
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
||||||
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
|
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
|
||||||
|
import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||||
|
|
@ -142,6 +143,7 @@ export const kotlinScopeResolver: ScopeResolver = {
|
||||||
attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes);
|
attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||||
attachKotlinSpringDiMetadata(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_AOP_FEATURE,
|
||||||
SPRING_BEAN_INVENTORY_FEATURE,
|
SPRING_BEAN_INVENTORY_FEATURE,
|
||||||
SPRING_CONDITIONALS_FEATURE,
|
SPRING_CONDITIONALS_FEATURE,
|
||||||
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||||
} from './ingestion/frameworks/spring/analysis-features.js';
|
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||||
import {
|
import {
|
||||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||||
|
|
@ -228,6 +229,7 @@ const ANALYSIS_FEATURES = [
|
||||||
SPRING_AOP_FEATURE,
|
SPRING_AOP_FEATURE,
|
||||||
SPRING_BEAN_INVENTORY_FEATURE,
|
SPRING_BEAN_INVENTORY_FEATURE,
|
||||||
SPRING_CONDITIONALS_FEATURE,
|
SPRING_CONDITIONALS_FEATURE,
|
||||||
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||||
JAVA_RECORD_COMPONENT_ACCESSORS_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
|
// 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.
|
// stale 2), which is the rule above: above every claim, not above origin/main.
|
||||||
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
|
// 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 = (() => {
|
const GITNEXUS_PKG_VERSION = (() => {
|
||||||
try {
|
try {
|
||||||
// package.json sits at gitnexus/package.json — two levels up from
|
// 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_AOP_FEATURE,
|
||||||
SPRING_BEAN_INVENTORY_FEATURE,
|
SPRING_BEAN_INVENTORY_FEATURE,
|
||||||
SPRING_CONDITIONALS_FEATURE,
|
SPRING_CONDITIONALS_FEATURE,
|
||||||
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||||
import {
|
import {
|
||||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||||
|
|
@ -21,6 +22,7 @@ const FEATURES = [
|
||||||
SPRING_AOP_FEATURE,
|
SPRING_AOP_FEATURE,
|
||||||
SPRING_BEAN_INVENTORY_FEATURE,
|
SPRING_BEAN_INVENTORY_FEATURE,
|
||||||
SPRING_CONDITIONALS_FEATURE,
|
SPRING_CONDITIONALS_FEATURE,
|
||||||
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||||
|
|
@ -39,12 +41,14 @@ describe('analysis feature versions', () => {
|
||||||
'spring.bean-inventory': 2,
|
'spring.bean-inventory': 2,
|
||||||
'spring.conditionals-auto-configuration': 1,
|
'spring.conditionals-auto-configuration': 1,
|
||||||
'spring.config-bindings': 1,
|
'spring.config-bindings': 1,
|
||||||
|
'spring.non-http-handlers': 1,
|
||||||
});
|
});
|
||||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
||||||
'graph.class-framework-annotations': 1,
|
'graph.class-framework-annotations': 1,
|
||||||
'spring.aop-advice': 1,
|
'spring.aop-advice': 1,
|
||||||
'spring.bean-inventory': 2,
|
'spring.bean-inventory': 2,
|
||||||
'spring.conditionals-auto-configuration': 1,
|
'spring.conditionals-auto-configuration': 1,
|
||||||
|
'spring.non-http-handlers': 1,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
resolveAnalysisFeatureVersions(FEATURES, [
|
resolveAnalysisFeatureVersions(FEATURES, [
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
SPRING_AOP_FEATURE,
|
SPRING_AOP_FEATURE,
|
||||||
SPRING_BEAN_INVENTORY_FEATURE,
|
SPRING_BEAN_INVENTORY_FEATURE,
|
||||||
SPRING_CONDITIONALS_FEATURE,
|
SPRING_CONDITIONALS_FEATURE,
|
||||||
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
||||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||||
import {
|
import {
|
||||||
decodeSpringAopReason,
|
decodeSpringAopReason,
|
||||||
|
|
@ -603,6 +604,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
||||||
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
||||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_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));
|
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_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
||||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||||
|
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
|
|
@ -690,6 +693,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
||||||
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
||||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||||
|
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
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
|
// 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
|
// 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.
|
// 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)', () => {
|
// Moved 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 /
|
||||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(68);
|
// #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
|
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||||
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
||||||
// Every nearby historical value is rejected: origin/main advanced through
|
// Every nearby historical or in-flight value is rejected: this branch
|
||||||
// 67, and this branch previously published 64. Pinning 68 and rejecting all
|
// previously published 59, origin/main advanced through 68, and #2972
|
||||||
// prior values makes an accidental conflict resolution loud.
|
// publishes 69. Rejecting all of them makes a bad conflict resolution loud.
|
||||||
for (const taken of [60, 61, 62, 63, 64, 65, 66, 67]) {
|
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);
|
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,14 @@ function captureKotlinSpringDiFacts(
|
||||||
return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
|
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', () => {
|
describe('Kotlin class annotation capture', () => {
|
||||||
it('captures supported class forms and excludes non-candidate declarations', () => {
|
it('captures supported class forms and excludes non-candidate declarations', () => {
|
||||||
const facts = captureKotlinClassAnnotations(`
|
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', () => {
|
describe('Kotlin Spring injection syntax capture', () => {
|
||||||
it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => {
|
it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => {
|
||||||
const facts = captureKotlinSpringDiFacts(`
|
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