diff --git a/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts b/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts new file mode 100644 index 000000000..cb7c9cf9d --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts @@ -0,0 +1,140 @@ +/** + * One argument of a Spring annotation or of a messaging-template call, captured + * exactly as it is written in source. + * + * Capture-time facts are deliberately UNRESOLVED. When these facts are produced + * the file's imports are not finalized, constants declared in sibling files do + * not exist yet, and no configuration source has been read — so a captured + * `text` may be a string literal, a constant reference (`Destinations.ORDERS`), + * a property placeholder (`"${app.orders.topic}"`), or an arbitrary expression. + * Turning any of those into an address is a separate, later phase; nothing here + * may call a resolver. + * + * NOT the same thing as `SpringAnnotationArgument` in `annotation-arguments.ts`, + * and the two are deliberately not merged: + * + * - Source. This fact is built from AST nodes while the tree is in hand; + * `parseSpringAnnotationArguments` re-parses an annotation's `text` much + * later, from a string, with a hand-written delimiter scanner. + * - Failure. The text parser returns `null` when its scanner cannot balance + * the input, and a caller must decide what that means. There is no such + * state here: the grammar has already decided where each argument begins + * and ends. + * - Absence. The text parser answers `[]` both for `@Scheduled` and for + * `@Scheduled()`, because a string cannot tell "no list" from "empty list" + * without re-deriving it. Capture keeps the two apart — absent versus `[]` — + * so downstream code can rely on the distinction wherever arguments were + * read at all. A capture that reads them for only some of its facts says so + * on its own `args` field. + * - Scope. This fact also describes CALL arguments (`template.send(topic, p)`), + * which the annotation parser has no notion of. + * + * Collapsing them would mean giving the text parser a failure mode it cannot + * produce, or taking the three-state distinction away from capture. + */ +export interface SpringArgumentFact { + /** + * Argument name for a named argument, absent for a positional one. + * + * Both forms occur, and where the destination sits differs by construct. An + * annotation names it (`@KafkaListener(topics = ...)` versus + * `@RabbitListener(queues = ...)`). A call normally gives it by position + * (`kafkaTemplate.send(topic, payload)`) — always so in Java, which has no + * named arguments — but a Kotlin call may name its arguments whenever the + * callee is itself declared in Kotlin, and then the key is captured too. + */ + readonly name?: string; + /** + * Argument value in its source spelling — quotes, braces and casts intact, + * nothing resolved — after `normalizeSpringFactText`. That pass trims the + * text and collapses whitespace around the dots of a multi-line expression, + * so one destination written two ways yields one fact. It is the only + * rewrite; see the function for why formatting must not reach the data. + */ + readonly text: string; +} + +/** + * Join an expression that the source wrapped across lines, so that one + * expression has one spelling no matter where it was written. + * + * A receiver chain written as `outer\n .inner\n .kafkaTemplate`, and an + * argument written as `Destinations\n .ORDERS`, are the same expressions as + * their single-line spellings. Raw node text would carry the newline and the + * ENCLOSING BLOCK's indentation across the worker boundary, so the same + * expression at two nesting depths — or in a CRLF checkout — would not compare + * equal downstream. Receivers and arguments get the identical treatment on + * purpose: an inconsistent rule inside one fact is a trap for the phase that + * has to match a publish against a subscription. + * + * Only a run of whitespace that CONTAINS A NEWLINE and sits next to a dot is + * removed, and only OUTSIDE a string literal. Single-line spacing is left + * alone, so `registry.get("a . b").template` keeps its argument exactly as + * written; literal-awareness extends that to Java text blocks and Kotlin raw + * strings, whose embedded newlines are part of the value and must survive + * (`"""line-a\n.line-b"""` is not the same string as `"""line-a.line-b"""`). + * + * Wraps that are not adjacent to a dot (`"a" +\n "b"`) are left as written: + * normalizing them would have to reason about operators, and the same + * conservatism already applies to receivers. + */ +export function normalizeSpringFactText(text: string): string { + const trimmed = text.trim(); + // Fast path: the overwhelming majority of captured text is single-line. + if (!trimmed.includes('\n') && !trimmed.includes('\r')) return trimmed; + + let out = ''; + let index = 0; + let quote: '"""' | '"' | "'" | null = null; + while (index < trimmed.length) { + const char = trimmed[index] as string; + if (quote === '"""') { + if (trimmed.startsWith('"""', index)) { + out += '"""'; + index += 3; + quote = null; + continue; + } + out += char; + index += 1; + continue; + } + if (quote !== null) { + // A backslash escape is copied whole so that `"\\"` ends the literal and + // `"\""` does not. + if (char === '\\' && index + 1 < trimmed.length) { + out += trimmed.slice(index, index + 2); + index += 2; + continue; + } + if (char === quote) quote = null; + out += char; + index += 1; + continue; + } + if (trimmed.startsWith('"""', index)) { + quote = '"""'; + out += '"""'; + index += 3; + continue; + } + if (char === '"' || char === "'") { + quote = char; + out += char; + index += 1; + continue; + } + if (char === '.' || /\s/.test(char)) { + const separator = /^\s*\.\s*/.exec(trimmed.slice(index)); + if (separator !== null) { + const matched = separator[0]; + out += matched.includes('\n') ? '.' : matched; + index += matched.length; + continue; + } + } + out += char; + index += 1; + } + return out; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts b/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts new file mode 100644 index 000000000..4a4a731dc --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts @@ -0,0 +1,140 @@ +import type { Range, ScopeId } from 'gitnexus-shared'; +import type { SpringArgumentFact } from './argument-facts.js'; + +/** + * Outbound side of Spring messaging: the template calls that publish to a + * broker destination, mirroring the inbound `@KafkaListener` / `@RabbitListener` + * family already recognized in `non-http-handlers.ts`. + * + * Recognition is purely syntactic and happens while the language's own scope + * query already has the call node in hand. The receiver's declared type is NOT + * consulted: at capture time the field may be inherited, injected from another + * file, or typed through an import that is not finalized yet. Matching on the + * receiver's simple name instead keeps the capture cheap and resolver-free; a + * later phase that owns type information can refine or discard a fact. + */ +export type SpringMessageProducerTemplate = 'kafka' | 'rabbit' | 'jms' | 'stream-bridge'; + +interface ProducerSignature { + readonly template: SpringMessageProducerTemplate; + /** + * Simple type name of the template bean, matched case-insensitively as a + * SUBSTRING of the receiver's folded simple name. The classifier below states + * which decorations that accepts, and what it does when one receiver name + * contains the type names of two different templates. + */ + readonly typeName: string; + readonly methodName: string; +} + +const PRODUCER_SIGNATURES: readonly ProducerSignature[] = [ + { template: 'kafka', typeName: 'KafkaTemplate', methodName: 'send' }, + { template: 'rabbit', typeName: 'RabbitTemplate', methodName: 'convertAndSend' }, + { template: 'jms', typeName: 'JmsTemplate', methodName: 'convertAndSend' }, + { template: 'stream-bridge', typeName: 'StreamBridge', methodName: 'send' }, +]; + +const PRODUCER_METHOD_NAMES: ReadonlySet = new Set( + PRODUCER_SIGNATURES.map((signature) => signature.methodName), +); + +/** A receiver we can attribute; `templates["k"]` or `getTemplate()` cannot be. */ +const PLAIN_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Fold a receiver's simple name to the form the type-name match runs against. + * + * `_` and `$` are word separators in the spellings this has to accept, not part + * of the words: `KAFKA_TEMPLATE` and `kafka_template` are the same bean name as + * `kafkaTemplate`, written to the constant and snake conventions. Digits stay, + * because they are part of a name (`kafkaTemplate2`), never a separator. + */ +function foldReceiverName(receiverSimpleName: string): string { + return receiverSimpleName.replace(/[_$]/g, '').toLowerCase(); +} + +/** + * Cheap pre-filter usable before any receiver text is materialized. Both + * languages visit every member call, so the common case must cost one set + * lookup on the method name. + */ +export function isSpringMessageProducerMethod(methodName: string): boolean { + return PRODUCER_METHOD_NAMES.has(methodName); +} + +/** + * Classify a `receiver.method(...)` call as a messaging producer, or `null`. + * + * `receiverName` is the receiver expression as written; only its last + * dot-separated segment participates, so `this.kafkaTemplate` and + * `outer.inner.kafkaTemplate` match while `templates.get("k")` does not. + * + * The PLAIN_IDENTIFIER gate runs BEFORE the fold and is load-bearing, because + * the last-dot split is textual: in `config.get("a.kafkaTemplate")` it yields + * `kafkaTemplate")`, which folds to something a name match would accept. Only + * an identifier survives the gate, which is also what rejects `templates["k"]`, + * `getTemplate()`, and a receiver whose dot is separated by a comment. + * + * The folded segment then matches case-insensitively when it CONTAINS the + * template type name, so every convention a template bean is really declared + * with is recognized — decorated by prefix (`orderKafkaTemplate`), by suffix + * (`kafkaTemplateDlq`, `kafkaTemplateV2`, `rabbitTemplate1`), or written as a + * constant (`KAFKA_TEMPLATE`, `STREAM_BRIDGE`). A suffix-only rule accepted + * one of those and silently dropped the rest, which are exactly the publishes + * this capture exists to find. A receiver named only `template` still does not + * match: without type information that would attribute any `send` in the + * repository to Kafka. + * + * The bare type name (`KafkaTemplate.send(...)`) contains itself and so is + * accepted. That is left as it is: the match is by NAME, a name equal to the + * type is the strongest evidence the rule has, and a later phase that owns type + * information can discard a static-looking receiver. + * + * A substring rule also lets ONE receiver satisfy TWO signatures, which a + * suffix rule could not: `KafkaTemplate` and `StreamBridge` both publish + * through `send`, and `RabbitTemplate` and `JmsTemplate` both through + * `convertAndSend`, so `streamBridgeKafkaTemplate.send(...)` matches two + * templates at once. Such a receiver yields NO fact. Nothing here can break the + * tie honestly: the receiver's TYPE is deliberately not resolved, and the name + * is not ranked evidence — neither the longest match, nor the last one, nor the + * order of this list says whether that bean is a KafkaTemplate fronted by a + * stream binding or a StreamBridge named after the broker behind it. Returning + * the first match published an arbitrary choice as a definite broker + * attribution, the one outcome a consumer cannot tell from a fact. Silence + * costs a rare publish and stays recoverable by a phase that owns types. + */ +export function springMessageProducerTemplateOf( + receiverName: string, + methodName: string, +): SpringMessageProducerTemplate | null { + if (!isSpringMessageProducerMethod(methodName)) return null; + const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1).trim(); + if (!PLAIN_IDENTIFIER.test(receiverSimpleName)) return null; + const folded = foldReceiverName(receiverSimpleName); + let matched: SpringMessageProducerTemplate | null = null; + for (const signature of PRODUCER_SIGNATURES) { + if (signature.methodName !== methodName) continue; + if (!folded.includes(signature.typeName.toLowerCase())) continue; + // A second match makes the receiver ambiguous; see above for why it is not + // resolved by preferring one of them. + if (matched !== null) return null; + matched = signature.template; + } + return matched; +} + +export interface SpringMessageProducerFact { + /** Callable that performs the publish; the enclosing method or function. */ + readonly ownerScopeId: ScopeId; + readonly ownerRange: Range; + readonly template: SpringMessageProducerTemplate; + /** Receiver expression as written, for example `this.orderKafkaTemplate`. */ + readonly receiverName: string; + readonly methodName: string; + /** + * Call arguments in source order, or absent when the call site has no + * argument list at all (a Kotlin trailing-lambda call). An empty array means + * an empty argument list was written — a different fact from no list. + */ + readonly args?: readonly SpringArgumentFact[]; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts index 8f3144d01..ae43f3eac 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts @@ -3,6 +3,7 @@ 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 type { SpringArgumentFact } from './argument-facts.js'; import { createSpringAnnotationNameResolver } from './bean-candidates.js'; import { SPRING_BEAN_ANNOTATION } from './bean-factories.js'; @@ -14,6 +15,34 @@ export interface SpringNonHttpHandlerAnnotationFact { readonly name: string; /** Kotlin use-site targets describe generated/property elements, not the callable. */ readonly useSiteTarget?: string; + /** + * Annotation arguments in source order. An empty array always means an empty + * list was written (`@Scheduled()`), which is a different fact from absence — + * but absence has TWO causes, and only one of them is a statement about the + * source. Either the annotation was written without an argument list + * (`@Scheduled`), or arguments were never read for this callable. + * + * They are read only for a callable that carries a handler annotation. Java + * produces facts for no other callable, so there absence does mean "no list + * was written". Kotlin also produces a fact for a merely annotated function — + * it captures those without a name prefilter so an import alias cannot hide a + * handler — and on those facts arguments are absent however the annotation + * was written. + * + * The values keep their source spelling, with one deliberate exception: + * `normalizeSpringFactText` trims them and collapses whitespace around the + * dots of a multi-line expression, so `Destinations.ORDERS` and the same + * reference wrapped across lines produce equal facts. Without that, source + * formatting — including the enclosing block's indentation, which is not a + * property of the expression at all — would leak into the data and make two + * spellings of one destination compare unequal downstream. + * + * Nothing else is touched. `@KafkaListener(topics = ...)` and + * `@RabbitListener(queues = ...)` name the destination differently, and a + * destination may be a literal, a constant reference, or a `${...}` + * placeholder; resolving any of those belongs to a later phase. + */ + readonly args?: readonly SpringArgumentFact[]; } export interface SpringNonHttpHandlerFact< diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 0da803429..a8c5e268e 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -14,6 +14,7 @@ import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -28,6 +29,7 @@ export interface JavaCaptureSideChannel { readonly springDiFacts?: readonly JavaSpringDiClassFact[]; readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[]; readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[]; + readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[]; } const classAnnotations = createClassAnnotationFactStore(); @@ -37,6 +39,7 @@ const springConditionalFacts = new Map(); const springDynamicLookupFacts = new Map(); const springNonHttpHandlerFacts = new Map(); +const springMessageProducerFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { @@ -47,6 +50,7 @@ export function clearJavaClassAnnotationFacts(): void { springDiFacts.clear(); springDynamicLookupFacts.clear(); springNonHttpHandlerFacts.clear(); + springMessageProducerFacts.clear(); } export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void { @@ -134,6 +138,20 @@ export function getJavaSpringNonHttpHandlerFacts( return springNonHttpHandlerFacts.get(filePath) ?? []; } +export function setJavaSpringMessageProducerFacts( + filePath: string, + facts: readonly SpringMessageProducerFact[], +): void { + if (facts.length === 0) springMessageProducerFacts.delete(filePath); + else springMessageProducerFacts.set(filePath, facts); +} + +export function getJavaSpringMessageProducerFacts( + filePath: string, +): readonly SpringMessageProducerFact[] { + return springMessageProducerFacts.get(filePath) ?? []; +} + /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */ export function collectJavaCaptureSideChannel( filePath: string, @@ -145,6 +163,7 @@ export function collectJavaCaptureSideChannel( const diFacts = springDiFacts.get(filePath) ?? []; const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; + const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && @@ -154,6 +173,7 @@ export function collectJavaCaptureSideChannel( diFacts.length === 0 && dynamicLookupFacts.length === 0 && nonHttpHandlerFacts.length === 0 && + messageProducerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -168,6 +188,9 @@ export function collectJavaCaptureSideChannel( ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), + ...(messageProducerFacts.length > 0 + ? { springMessageProducerFacts: messageProducerFacts } + : {}), }; } @@ -192,6 +215,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { setJavaSpringDiFacts(parsed.filePath, []); setJavaSpringDynamicLookupFacts(parsed.filePath, []); setJavaSpringNonHttpHandlerFacts(parsed.filePath, []); + setJavaSpringMessageProducerFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -220,6 +244,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], ); + setJavaSpringMessageProducerFacts( + parsed.filePath, + Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [], + ); setJavaPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 2c3c2c281..5d31aed4a 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -40,6 +40,7 @@ import { setJavaSpringConditionalFacts, setJavaSpringDiFacts, setJavaSpringDynamicLookupFacts, + setJavaSpringMessageProducerFacts, setJavaSpringNonHttpHandlerFacts, } from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; @@ -48,6 +49,8 @@ import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.j import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; import { captureJavaSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; +import { captureJavaSpringMessageProducerFact } from './spring-message-producers.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js'; import { @@ -151,7 +154,8 @@ export function emitJavaScopeCaptures( const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = []; const springDiClassNodeIds = new Set(); const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; - const springDynamicLookupNodeIds = new Set(); + const springMessageProducerFacts: SpringMessageProducerFact[] = []; + const springMemberCallNodeIds = new Set(); for (const m of rawMatches) { const grouped: Record = {}; @@ -171,11 +175,15 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; - const dynamicLookupNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation'); - if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) { - springDynamicLookupNodeIds.add(dynamicLookupNode.id); - const fact = captureJavaSpringDynamicLookupFact(dynamicLookupNode, filePath); - if (fact !== null) springDynamicLookupFacts.push(fact); + // One visit per member call node: the same invocation can back several + // query matches, and both Spring call-shape captures must see it once. + const memberCallNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation'); + if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) { + springMemberCallNodeIds.add(memberCallNode.id); + const lookupFact = captureJavaSpringDynamicLookupFact(memberCallNode, filePath); + if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact); + const producerFact = captureJavaSpringMessageProducerFact(memberCallNode, filePath); + if (producerFact !== null) springMessageProducerFacts.push(producerFact); } const springAopTypeNode = [ @@ -416,6 +424,7 @@ export function emitJavaScopeCaptures( setJavaSpringDiFacts(filePath, springDiFacts); setJavaSpringDynamicLookupFacts(filePath, springDynamicLookupFacts); setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); + setJavaSpringMessageProducerFacts(filePath, springMessageProducerFacts); return [ ...resolveVarTypeBindings(out), diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts index c6dcbe261..121cd1e3b 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -12,13 +12,72 @@ import { hasSpringBeanFactorySyntax, type SpringBeanFactoryMethodFact, } from '../../frameworks/spring/bean-factories.js'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; import { parseSpringInjectionType } from '../../di-extractors/spring.js'; -import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; import { getJavaSpringDiFacts } from './capture-side-channel.js'; export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact { readonly line: number; + /** Present only for callers that opt in via `javaSpringAnnotationFacts`. */ + readonly args?: readonly SpringArgumentFact[]; +} + +/** + * Options for `javaSpringAnnotationFacts`. + * + * The STRUCTURED arguments are opt-in because DI captures every annotated + * field, constructor, and method in the repository, and none of its consumers + * reads them. Note what this does and does not save: every fact already carries + * `text`, the annotation's full source, so the argument TEXT crosses the worker + * boundary either way. What the opt-in avoids is a second, parsed copy of that + * same text on facts that would never look at it. + */ +export interface JavaSpringAnnotationFactOptions { + readonly includeArguments?: boolean; +} + +const JAVA_COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']); + +/** + * Annotation arguments as written, or `undefined` for a marker annotation. + * + * `@Scheduled` yields `undefined` (no argument list in the syntax) while + * `@Scheduled()` yields `[]` (an empty list was written). Named arguments keep + * their key, single-element ones stay positional, and array initializers are + * kept as one raw `{...}` text — splitting or dereferencing them would be + * resolution, which does not belong at capture time. + * + * An argument list that did not parse also yields `undefined`. Error recovery + * fills gaps with invented nodes — `@KafkaListener(topics = "orders", groupId =` + * hands back a `groupId` whose value is a `{}` that nobody wrote — and there is + * no fourth state here for "unreadable". Collapsing it into the marker case is + * deliberate: both tell a consumer there is nothing here to resolve, which is + * true, whereas a fabricated value would send it somewhere real and wrong. + */ +function javaAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined { + const argumentList = annotation.childForFieldName('arguments'); + if (argumentList === null || hasRecoveredSyntax(argumentList)) return undefined; + const args: SpringArgumentFact[] = []; + for (const child of argumentList.namedChildren) { + if (JAVA_COMMENT_NODE_TYPES.has(child.type)) continue; + if (child.type === 'element_value_pair') { + const key = child.childForFieldName('key'); + const value = child.childForFieldName('value'); + if (key === null || value === null) { + args.push({ text: normalizeSpringFactText(child.text) }); + continue; + } + args.push({ name: key.text.trim(), text: normalizeSpringFactText(value.text) }); + continue; + } + args.push({ text: normalizeSpringFactText(child.text) }); + } + return args; } export type JavaSpringDependencyFact = SpringDiDependencyFact; @@ -36,7 +95,10 @@ export type JavaSpringDiClassFact = SpringDiClassFact< >; type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact; -export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { +export function javaSpringAnnotationFacts( + node: SyntaxNode, + options: JavaSpringAnnotationFactOptions = {}, +): JavaAnnotationSyntaxFact[] { const facts: JavaAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers') continue; @@ -44,10 +106,13 @@ export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSynta if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue; const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild; if (nameNode === null) continue; + const args = + options.includeArguments === true ? javaAnnotationArgumentFacts(modifier) : undefined; facts.push({ name: nameNode.text.trim(), text: modifier.text.trim(), line: modifier.startPosition.row + 1, + ...(args === undefined ? {} : { args }), }); } } diff --git a/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts b/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts new file mode 100644 index 000000000..ce59015ba --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts @@ -0,0 +1,100 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; +import { + isSpringMessageProducerMethod, + springMessageProducerTemplateOf, + type SpringMessageProducerFact, +} from '../../frameworks/spring/message-producers.js'; +import { + findAncestorBeforeBoundary, + hasRecoveredSyntax, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; + +const CALLABLE_NODE_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', +]); +/** + * A type body ends the search for the publishing callable. + * + * Without it the ancestor walk passes THROUGH the body of a class declared + * inside a method, so a publish in that class's field initializer is attributed + * to the enclosing method, which may never run it. The identical construct at + * the top level of a class already yields no fact — there is no enclosing + * callable to find — and the rule has to read the same at every depth. + */ +const TYPE_BODY_BOUNDARIES = new Set([ + 'class_body', + 'interface_body', + 'enum_body', + 'enum_body_declarations', + 'annotation_type_body', +]); +const COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']); + +/** Java has no named call arguments, so every argument is captured positionally. */ +function javaCallArgumentFacts(argumentList: SyntaxNode): SpringArgumentFact[] { + return argumentList.namedChildren + .filter((child) => !COMMENT_NODE_TYPES.has(child.type)) + .map((child) => ({ text: normalizeSpringFactText(child.text) })); +} + +/** + * Capture one messaging-template publish from a Java call already surfaced by + * the scope query, without resolving the destination it names. + * + * The destination argument may be a literal, a reference to a constant that + * lives in another file, or a `${...}` placeholder resolved from configuration; + * all three are recorded as written and left to a later phase. + * + * A call whose argument list did not parse yields NO fact. The fact exists to + * carry a destination, and error recovery invents argument boundaries — an + * unterminated `send(TOPIC,` absorbs the next declaration's source and offers + * it as an argument. There is no state on this fact that means "published + * somewhere unreadable", so the choice is between silence and a plausible lie, + * and silence is recoverable: the file is re-captured when it parses. + */ +export function captureJavaSpringMessageProducerFact( + node: SyntaxNode, + filePath: string, +): SpringMessageProducerFact | null { + if (node.type !== 'method_invocation') return null; + const methodName = node.childForFieldName('name')?.text.trim(); + if (methodName === undefined || !isSpringMessageProducerMethod(methodName)) return null; + const receiverText = node.childForFieldName('object')?.text; + if (receiverText === undefined) return null; + const receiverName = normalizeSpringFactText(receiverText); + const template = springMessageProducerTemplateOf(receiverName, methodName); + if (template === null) return null; + + const argumentList = node.childForFieldName('arguments'); + if (argumentList !== null && hasRecoveredSyntax(argumentList)) return null; + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, TYPE_BODY_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-message-producer.owner', owner); + return { + ownerScopeId: makeScopeId({ filePath, range: ownerCapture.range, kind: 'Function' }), + ownerRange: ownerCapture.range, + template, + receiverName, + methodName, + ...(argumentList === null ? {} : { args: javaCallArgumentFacts(argumentList) }), + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureJavaSpringMessageProducerFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringMessageProducerFact[] { + return rootNode + .descendantsOfType('method_invocation') + .map((node) => captureJavaSpringMessageProducerFact(node, filePath)) + .filter((fact): fact is SpringMessageProducerFact => fact !== null); +} diff --git a/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts index 259dace3a..a9e7c867e 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts @@ -11,7 +11,17 @@ import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spri export type JavaSpringNonHttpHandlerFact = SpringNonHttpHandlerFact; -/** Capture callable syntax while the Java class AST is already in hand. */ +/** + * Capture callable syntax while the Java class AST is already in hand. + * + * Annotation arguments are read in a second pass, only for callables that + * already carry a handler annotation, so the destination-bearing arguments + * (`topics`, `queues`, `destination`, `cron`) reach the fact without adding + * structured argument text to every annotation in the repository. Java can + * decide that on the simple name alone; Kotlin runs the same two passes but + * widens the first one with the file's import aliases, because a Kotlin handler + * annotation may be written under a name no list can contain. + */ export function captureJavaSpringNonHttpHandlerFacts( classNode: SyntaxNode, filePath: string, @@ -21,8 +31,8 @@ export function captureJavaSpringNonHttpHandlerFacts( 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; + if (!hasSpringNonHttpHandlerRelevantAnnotation(javaSpringAnnotationFacts(member))) continue; + const annotations = javaSpringAnnotationFacts(member, { includeArguments: true }); const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; facts.push({ ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }), diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index cda62fb88..6983e55ec 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -50,6 +50,7 @@ import { import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; @@ -63,6 +64,7 @@ const springDiFacts = new Map(); const springDynamicLookupFacts = new Map(); const springNonHttpHandlerFacts = new Map(); const springConfigConsumerFacts = new Map(); +const springMessageProducerFacts = new Map(); /** * Plain JSON-serializable snapshot of the per-file Kotlin capture-time @@ -90,6 +92,8 @@ export interface KotlinCaptureSideChannel { readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[]; /** `@Value` / `@ConfigurationProperties` syntax captured per owner. */ readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[]; + /** Messaging-template publish syntax captured per callable. */ + readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[]; } export function clearKotlinClassAnnotationFacts(): void { @@ -100,6 +104,7 @@ export function clearKotlinClassAnnotationFacts(): void { springDynamicLookupFacts.clear(); springNonHttpHandlerFacts.clear(); springConfigConsumerFacts.clear(); + springMessageProducerFacts.clear(); } export function setKotlinSpringAopFacts( @@ -193,6 +198,20 @@ export function getKotlinSpringConfigConsumerFacts( return springConfigConsumerFacts.get(filePath) ?? []; } +export function setKotlinSpringMessageProducerFacts( + filePath: string, + facts: readonly SpringMessageProducerFact[], +): void { + if (facts.length === 0) springMessageProducerFacts.delete(filePath); + else springMessageProducerFacts.set(filePath, facts); +} + +export function getKotlinSpringMessageProducerFacts( + filePath: string, +): readonly SpringMessageProducerFact[] { + return springMessageProducerFacts.get(filePath) ?? []; +} + /** * `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin. * Returns `undefined` when this file recorded no side-channel state at all, so @@ -209,6 +228,7 @@ export function collectKotlinCaptureSideChannel( const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? []; + const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && @@ -219,6 +239,7 @@ export function collectKotlinCaptureSideChannel( dynamicLookupFacts.length === 0 && nonHttpHandlerFacts.length === 0 && configConsumerFacts.length === 0 && + messageProducerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -234,6 +255,9 @@ export function collectKotlinCaptureSideChannel( ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), ...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}), + ...(messageProducerFacts.length > 0 + ? { springMessageProducerFacts: messageProducerFacts } + : {}), }; } @@ -262,6 +286,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { setKotlinSpringDynamicLookupFacts(parsed.filePath, []); setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []); setKotlinSpringConfigConsumerFacts(parsed.filePath, []); + setKotlinSpringMessageProducerFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -293,6 +318,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : [], ); + setKotlinSpringMessageProducerFacts( + parsed.filePath, + Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [], + ); setKotlinPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 7688a527f..a24073324 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -24,6 +24,7 @@ import { setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, + setKotlinSpringMessageProducerFacts, setKotlinSpringNonHttpHandlerFacts, setKotlinSpringConfigConsumerFacts, } from './capture-side-channel.js'; @@ -34,6 +35,8 @@ import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './ import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js'; import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; +import { captureKotlinSpringMessageProducerFact } from './spring-message-producers.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js'; import { @@ -114,7 +117,8 @@ export function emitKotlinScopeCaptures( const springNonHttpHandlerTypeNodeIds = new Set(); const springDiClassNodeIds = new Set(); const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; - const springDynamicLookupNodeIds = new Set(); + const springMessageProducerFacts: SpringMessageProducerFact[] = []; + const springMemberCallNodeIds = new Set(); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes)); out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes)); @@ -138,11 +142,15 @@ export function emitKotlinScopeCaptures( } if (Object.keys(grouped).length === 0) continue; - const dynamicLookupNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression'); - if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) { - springDynamicLookupNodeIds.add(dynamicLookupNode.id); - const fact = captureKotlinSpringDynamicLookupFact(dynamicLookupNode, filePath); - if (fact !== null) springDynamicLookupFacts.push(fact); + // One visit per member call node: the same invocation can back several + // query matches, and both Spring call-shape captures must see it once. + const memberCallNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression'); + if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) { + springMemberCallNodeIds.add(memberCallNode.id); + const lookupFact = captureKotlinSpringDynamicLookupFact(memberCallNode, filePath); + if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact); + const producerFact = captureKotlinSpringMessageProducerFact(memberCallNode, filePath); + if (producerFact !== null) springMessageProducerFacts.push(producerFact); } // tree-sitter-kotlin represents both classes and interfaces with @@ -378,6 +386,7 @@ export function emitKotlinScopeCaptures( filePath, captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath), ); + setKotlinSpringMessageProducerFacts(filePath, springMessageProducerFacts); out.push(...synthesizeLombokAccessorCaptures(tree.rootNode)); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); return out; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts index acb38360e..af0ba2cec 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -1,5 +1,9 @@ import { makeScopeId } from 'gitnexus-shared'; import { parseSpringInjectionType } from '../../di-extractors/spring.js'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; import { createSpringDiMetadataAttacher, hasSpringDiRelevantAnnotation, @@ -13,13 +17,29 @@ import { hasSpringBeanFactorySyntax, type SpringBeanFactoryMethodFact, } from '../../frameworks/spring/bean-factories.js'; -import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { getKotlinSpringDiFacts } from './capture-side-channel.js'; import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact { readonly useSiteTarget?: string; readonly line: number; + /** Present only for callers that opt in via `kotlinSpringAnnotationFacts`. */ + readonly args?: readonly SpringArgumentFact[]; +} + +/** + * Options for `kotlinSpringAnnotationFacts`. + * + * The STRUCTURED arguments are opt-in because DI captures every annotated + * constructor parameter, property, and function in the repository, and none of + * its consumers reads them. Note what this does and does not save: every fact + * already carries `text`, the annotation's full source, so the argument TEXT + * crosses the worker boundary either way. What the opt-in avoids is a second, + * parsed copy of that same text on facts that would never look at it. + */ +export interface KotlinSpringAnnotationFactOptions { + readonly includeArguments?: boolean; } export type KotlinSpringDependencyFact = SpringDiDependencyFact; @@ -53,36 +73,128 @@ function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | und return undefined; } -function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null { +const KOTLIN_COMMENT_NODE_TYPES = new Set(['line_comment', 'multiline_comment']); + +/** + * Kotlin writes annotation arguments and call arguments with the same + * `value_arguments` node, so one reader serves `@KafkaListener(topics = [...])` + * and `kafkaTemplate.send(topic, payload)`. + * + * A named argument keeps its key; everything else — positional values, spreads, + * collection literals, and interpolated strings — is kept as raw text, because + * evaluating it would be resolution. + * + * Returns `null` for a list tree-sitter had to recover, and the callers decide + * what that means: a producer call drops the whole fact, since it has no state + * for "published somewhere unreadable", while an annotation reports no + * arguments and collapses into the marker form. Both answers say "nothing here + * to resolve", which is true; a fabricated value would send a consumer + * somewhere real and wrong. + * + * The check lives HERE, not only in the callers. This function is exported and + * already has a caller in another module, so a guard that every future caller + * has to remember is the same fragility this change set exists to remove — + * `null` makes the decision unavoidable at the type level. Per-argument + * re-checks are still pointless: `hasError` propagates from any argument up to + * the list, so a branch behind this one could never fire. + * + * A named argument is identified by the `=` TOKEN, and the two-child shape is + * only a corroborating detail. Today nothing well formed reaches two children + * without an `=`: an annotated positional argument such as + * `@Suppress("UNCHECKED_CAST") "orders"` arrives as ONE `prefix_expression`, not + * as two children, so the token test is currently redundant. It is kept as the + * leading condition anyway, because the failure it prevents is asymmetric — + * dropping it would let any future two-child positional shape be reported under + * an argument key the source never wrote, which is the failure mode this whole + * change set is about. + */ +export function kotlinValueArgumentFacts(valueArguments: SyntaxNode): SpringArgumentFact[] | null { + if (hasRecoveredSyntax(valueArguments)) return null; + const args: SpringArgumentFact[] = []; + for (const argument of valueArguments.namedChildren) { + if (argument.type !== 'value_argument') continue; + const parts = argument.namedChildren.filter( + (child) => !KOTLIN_COMMENT_NODE_TYPES.has(child.type), + ); + const named = argument.children.some((child) => child.type === '='); + const name = parts[0]; + const value = parts[1]; + if (named && parts.length === 2 && name !== undefined && value !== undefined) { + args.push({ name: name.text.trim(), text: normalizeSpringFactText(value.text) }); + continue; + } + args.push({ text: normalizeSpringFactText(argument.text) }); + } + return args; +} + +/** + * Arguments of one annotation, or `undefined` when it was written without an + * argument list (`@Scheduled`); `@Scheduled()` yields `[]` instead. + * + * Only the annotation's FIRST `user_type` / `constructor_invocation` child is + * read, which is the same element `annotationFact` names. That matters for the + * multi-annotation form `@field:[Alpha Beta("x")]`, where naively taking the + * first constructor invocation would hand Beta's arguments to Alpha. + * + * An argument list that did not parse also yields `undefined`, collapsing into + * the marker-annotation case on purpose: both say there is nothing readable to + * resolve, while the recovered tree would offer values nobody wrote. + */ +function kotlinAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined { + const named = annotation.namedChildren.find( + (child) => child.type === 'user_type' || child.type === 'constructor_invocation', + ); + if (named === undefined || named.type !== 'constructor_invocation') return undefined; + const valueArguments = named.namedChildren.find((child) => child.type === 'value_arguments'); + if (valueArguments === undefined) return undefined; + // `null` here means recovered syntax; an annotation answers that by reporting + // no arguments at all, which is the marker-annotation form. + return kotlinValueArgumentFacts(valueArguments) ?? undefined; +} + +function annotationFact( + annotation: SyntaxNode, + options: KotlinSpringAnnotationFactOptions, +): KotlinAnnotationSyntaxFact | null { const nameNode = firstDescendantOfType(annotation, 'user_type'); if (nameNode === undefined) return null; const useSiteTarget = annotation.namedChildren .find((child) => child.type === 'use_site_target') ?.text.replace(/:\s*$/, '') .trim(); + const args = + options.includeArguments === true ? kotlinAnnotationArgumentFacts(annotation) : undefined; return { name: nameNode.text.trim(), text: annotation.text.trim(), line: annotation.startPosition.row + 1, ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), + ...(args === undefined ? {} : { args }), }; } -function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { +function annotationsFromModifierContainer( + node: SyntaxNode, + options: KotlinSpringAnnotationFactOptions = {}, +): KotlinAnnotationSyntaxFact[] { const facts: KotlinAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'annotation') continue; - const fact = annotationFact(child); + const fact = annotationFact(child, options); if (fact !== null) facts.push(fact); } return facts; } -export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { +export function kotlinSpringAnnotationFacts( + node: SyntaxNode, + options: KotlinSpringAnnotationFactOptions = {}, +): KotlinAnnotationSyntaxFact[] { const facts: KotlinAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; - facts.push(...annotationsFromModifierContainer(child)); + facts.push(...annotationsFromModifierContainer(child, options)); } return facts; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts new file mode 100644 index 000000000..7f32709c8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts @@ -0,0 +1,132 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { normalizeSpringFactText } from '../../frameworks/spring/argument-facts.js'; +import { + isSpringMessageProducerMethod, + springMessageProducerTemplateOf, + type SpringMessageProducerFact, +} from '../../frameworks/spring/message-producers.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { kotlinValueArgumentFacts } from './spring-di.js'; + +// Kotlin emits graph callables for functions and secondary constructors. +// `init {}` / primary-constructor bodies have no independent callable node, so +// attributing their publishes to the enclosing Class would violate graph +// semantics. +const CALLABLE_NODE_TYPES = new Set(['function_declaration', 'secondary_constructor']); +/** + * A class body ends the search, so the rule above holds at every depth. + * + * Without it the walk passes THROUGH the body of a class or object declared + * inside a function, and the publish in that body's property initializer — which + * likewise has no callable of its own — is attributed to the enclosing function + * instead of being dropped the way its top-level twin is. + */ +const TYPE_BODY_BOUNDARIES = new Set(['class_body', 'enum_class_body']); + +/** + * Strip null-assertion operators from a receiver. + * + * `?.` carries its marker on the navigation suffix, which the structural split + * already discards, but `!!` wraps the receiver in a `postfix_expression` whose + * text ends in the operator — enough to make `kafkaTemplate!!` fail the + * receiver-name check and lose a publish. Unwrapping is limited to `!!` + * because `counter++` produces the same node shape and is not a receiver name. + */ +function withoutNullAssertions(receiver: SyntaxNode): SyntaxNode { + let current = receiver; + while (current.type === 'postfix_expression') { + const operand = current.namedChildren[0]; + if (operand === undefined) return current; + const onlyNullAssertions = current.children.every( + (child) => child.id === operand.id || child.type === '!!', + ); + if (!onlyNullAssertions) return current; + current = operand; + } + return current; +} + +/** + * Split `receiver.method` structurally rather than by text. + * + * Text splitting would leave the safe-call marker on the receiver + * (`kafkaTemplate?` for `kafkaTemplate?.send(...)`). + */ +function navigationParts(callee: SyntaxNode): { receiverName: string; methodName: string } | null { + if (callee.type !== 'navigation_expression') return null; + const suffix = callee.namedChildren.find((child) => child.type === 'navigation_suffix'); + const receiver = callee.namedChildren.find((child) => child.type !== 'navigation_suffix'); + if (suffix === undefined || receiver === undefined) return null; + const methodName = suffix.namedChildren + .find((child) => child.type === 'simple_identifier') + ?.text.trim(); + if (methodName === undefined) return null; + return { + receiverName: normalizeSpringFactText(withoutNullAssertions(receiver).text), + methodName, + }; +} + +/** + * Capture one messaging-template publish from a Kotlin call already surfaced by + * the scope query, without resolving the destination it names. + * + * The destination argument may be a literal, a reference to a constant that + * lives in another file, or a `${...}` placeholder resolved from configuration; + * all three are recorded as written and left to a later phase. + * + * A call whose argument list did not parse yields NO fact, for the reason given + * on the Java side: error recovery guesses argument boundaries, and this fact + * has no way to say "published somewhere unreadable". + */ +export function captureKotlinSpringMessageProducerFact( + node: SyntaxNode, + filePath: string, +): SpringMessageProducerFact | null { + if (node.type !== 'call_expression') return null; + const callee = node.namedChildren[0]; + if (callee === undefined) return null; + const parts = navigationParts(callee); + if (parts === null || !isSpringMessageProducerMethod(parts.methodName)) return null; + const template = springMessageProducerTemplateOf(parts.receiverName, parts.methodName); + if (template === null) return null; + + const callSuffix = node.namedChildren.find((child) => child.type === 'call_suffix'); + // A trailing-lambda call (`send { ... }`) has no argument list at all, which + // is a different fact from an empty one (`send()`). + const valueArguments = callSuffix?.namedChildren.find( + (child) => child.type === 'value_arguments', + ); + // `null` from the reader means tree-sitter had to recover the list. A publish + // fact exists to carry a destination and has no state for "published + // somewhere unreadable", so the whole fact is withheld rather than reported + // with arguments the source never wrote. + const args = valueArguments === undefined ? undefined : kotlinValueArgumentFacts(valueArguments); + if (args === null) return null; + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, TYPE_BODY_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-message-producer.owner', owner); + return { + ownerScopeId: makeScopeId({ filePath, range: ownerCapture.range, kind: 'Function' }), + ownerRange: ownerCapture.range, + template, + receiverName: parts.receiverName, + methodName: parts.methodName, + ...(args === undefined ? {} : { args }), + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureKotlinSpringMessageProducerFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringMessageProducerFact[] { + return rootNode + .descendantsOfType('call_expression') + .map((node) => captureKotlinSpringMessageProducerFact(node, filePath)) + .filter((fact): fact is SpringMessageProducerFact => fact !== null); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts index b46132073..a7b21ffd4 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts @@ -1,6 +1,7 @@ import { makeScopeId } from 'gitnexus-shared'; import { createSpringNonHttpHandlerMetadataAttacher, + hasSpringNonHttpHandlerRelevantAnnotation, type SpringNonHttpHandlerAnnotationFact, type SpringNonHttpHandlerFact, } from '../../frameworks/spring/non-http-handlers.js'; @@ -12,10 +13,68 @@ import { kotlinSpringAnnotationFacts } from './spring-di.js'; export type KotlinSpringNonHttpHandlerFact = SpringNonHttpHandlerFact; +/** + * Local names that reach a handler annotation only through an import alias. + * + * `import ...event.EventListener as SpringEvent` makes `@SpringEvent` a handler + * annotation whose simple name matches nothing, which is why the CALLABLE + * capture below has no name prefilter. The alias is not a mystery at capture + * time, though: the import header states both the local name and the FQN it + * stands for, so the same relevance predicate that Java uses on the annotation + * name can be applied to the IMPORTED name and the answer carried back to the + * alias. That recovers a name-based decision without discarding aliases. + * + * Only aliases are collected. A plain or wildcard import leaves the annotation + * written under its own simple name, which the direct check already sees. + */ +function aliasedHandlerAnnotationNames(classNode: SyntaxNode): ReadonlySet { + let root: SyntaxNode = classNode; + while (root.parent !== null) root = root.parent; + + const headers: SyntaxNode[] = []; + for (const child of root.namedChildren) { + if (child.type === 'import_header') headers.push(child); + else if (child.type === 'import_list') { + for (const header of child.namedChildren) { + if (header.type === 'import_header') headers.push(header); + } + } + } + + const aliases = new Set(); + for (const header of headers) { + const alias = header.namedChildren + .find((child) => child.type === 'import_alias') + ?.namedChildren.find((child) => child.type === 'type_identifier') + ?.text.trim(); + if (alias === undefined || alias.length === 0) continue; + const imported = header.namedChildren.find((child) => child.type === 'identifier')?.text.trim(); + if (imported === undefined || imported.length === 0) continue; + if (hasSpringNonHttpHandlerRelevantAnnotation([{ name: imported }])) aliases.add(alias); + } + return aliases; +} + /** * 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. + * + * That conservatism applies to the CALLABLE — every annotated function still + * produces a fact, whatever its annotations are named. It does NOT have to + * apply to the arguments: reading them unconditionally charged every + * `@Transactional` and `@Deprecated` in a repository for data no consumer + * reads, and unlike the callable itself an argument list can be fetched on + * evidence. Arguments are therefore read in a second pass, for callables that + * either carry a handler annotation under its own name or use a local name this + * file aliased to one — the same two-pass shape as Java, with the alias set + * standing in for the name prefilter Kotlin cannot use. + * + * Measured on 200 annotated NON-handler functions in one file: the side-channel + * payload was 41069 bytes before arguments existed, 78797 with them read + * unconditionally, and 41069 again with this pass — byte for byte what it cost + * before the feature. The 200-handler equivalent pays 58649, which is the + * argument text the consumer asked for. */ export function captureKotlinSpringNonHttpHandlerFacts( classNode: SyntaxNode, @@ -26,9 +85,23 @@ export function captureKotlinSpringNonHttpHandlerFacts( (child) => child.type === 'class_body' || child.type === 'enum_class_body', ); if (body === undefined) return facts; + // Read the import headers at most once per class, and only when some callable + // actually fails the direct name check. + let aliasedHandlerNames: ReadonlySet | undefined; for (const member of body.namedChildren) { if (member.type !== 'function_declaration') continue; - const annotations = kotlinSpringAnnotationFacts(member); + const named = kotlinSpringAnnotationFacts(member); + if (named.length === 0) continue; + let readArguments = hasSpringNonHttpHandlerRelevantAnnotation(named); + if (!readArguments) { + aliasedHandlerNames ??= aliasedHandlerAnnotationNames(classNode); + readArguments = named.some( + (annotation) => aliasedHandlerNames?.has(annotation.name) === true, + ); + } + const annotations = readArguments + ? kotlinSpringAnnotationFacts(member, { includeArguments: true }) + : named; if (annotations.length === 0) continue; const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; facts.push({ @@ -40,6 +113,7 @@ export function captureKotlinSpringNonHttpHandlerFacts( ...(annotation.useSiteTarget === undefined ? {} : { useSiteTarget: annotation.useSiteTarget }), + ...(annotation.args === undefined ? {} : { args: annotation.args }), })), }); } diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 1a88c9a15..2349b1426 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -462,6 +462,25 @@ export function walkNamedTree(node: SyntaxNode, cb: (node: SyntaxNode) => void): } } +/** + * True when a node is, or contains, tree-sitter error recovery. + * + * After a syntax error the parser keeps going by guessing node boundaries, so + * the surviving tree stays WELL FORMED while describing text that was never + * written that way: an unterminated argument list can absorb the source of the + * next declaration into an `ERROR` child, and an assignment with no right-hand + * side gets a `MISSING` value node whose text is invented. A capture that reads + * such a subtree emits facts that look ordinary and are false, which is worse + * than emitting nothing — so callers that record source text verbatim should + * check this first and fail closed. + * + * `hasError` covers the subtree; `isMissing` is checked as well because a node + * inserted by recovery is the one case where the node itself carries the flag. + */ +export function hasRecoveredSyntax(node: SyntaxNode): boolean { + return node.hasError || node.isMissing; +} + /** Return the first matching ancestor unless a boundary ancestor is reached first. */ export function findAncestorBeforeBoundary( node: SyntaxNode, diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index ecd7b4466..1dcd4701f 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -692,7 +692,20 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // 87 -> 88: Java ModuleConstants now preserves unfoldable declaration names // across worker/cache replay so wildcard expansion cannot resurrect an imported // member hidden by a local field. A warm v87 cache lacks that shadow metadata. -const SCHEMA_BUMP = 88; +// 88 -> 89: the same side channels now carry Spring messaging facts — the +// arguments of non-HTTP handler annotations (`@KafkaListener(topics = ...)`) +// and a new `springMessageProducerFacts` list for template publishes +// (`KafkaTemplate.send`, `RabbitTemplate`/`JmsTemplate.convertAndSend`, +// `StreamBridge.send`). Both are parse-time worker output replayed verbatim +// from `ParsedFile.captureSideChannel`, so a warm v88 cache would skip workers +// and hand the annotation facts back with no `args` and the producer list +// empty. Measured on the fixture app: a warm all-cache-hit run +// (`usedWorkerPool=false`, `reparsedFileCount=0`) reproduces 6 Java and 7 +// Kotlin producer facts purely from the store, which is exactly the state a +// pre-change cache would have served as zero. origin/main at allocation is 88. +// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING — this +// entry was allocated 83 first, and five bumps landed upstream before it merged. +const SCHEMA_BUMP = 89; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/OrderPublishers.java b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/OrderPublishers.java new file mode 100644 index 000000000..ecc98febd --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/OrderPublishers.java @@ -0,0 +1,54 @@ +package com.example.handlers; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.jms.core.JmsTemplate; +import org.springframework.kafka.core.KafkaTemplate; + +public class OrderPublishers { + private static final String SHIPMENTS_TOPIC = "shipments"; + + private final KafkaTemplate kafkaTemplate; + private final RabbitTemplate rabbitTemplate; + private final JmsTemplate jmsTemplate; + private final StreamBridge streamBridge; + + @Value("${app.messaging.orders-topic}") + private String ordersTopic; + + public OrderPublishers( + KafkaTemplate kafkaTemplate, + RabbitTemplate rabbitTemplate, + JmsTemplate jmsTemplate, + StreamBridge streamBridge) { + this.kafkaTemplate = kafkaTemplate; + this.rabbitTemplate = rabbitTemplate; + this.jmsTemplate = jmsTemplate; + this.streamBridge = streamBridge; + } + + public void publishLiteralDestination(String payload) { + kafkaTemplate.send("orders", payload); + } + + public void publishConstantDestination(String payload) { + this.kafkaTemplate.send(SHIPMENTS_TOPIC, payload); + } + + public void publishConfiguredDestination(String payload) { + kafkaTemplate.send(ordersTopic, payload); + } + + public void publishToExchange(String exchange, String routingKey, String payload) { + rabbitTemplate.convertAndSend(exchange, routingKey, payload); + } + + public void publishToQueue(String payload) { + jmsTemplate.convertAndSend("queue.orders", payload); + } + + public void publishToBinding(String payload) { + streamBridge.send("orders-out-0", payload); + } +} diff --git a/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/OrderPublishers.kt b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/OrderPublishers.kt new file mode 100644 index 000000000..3eb55f7fb --- /dev/null +++ b/gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/OrderPublishers.kt @@ -0,0 +1,41 @@ +package com.example.handlers + +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Value +import org.springframework.cloud.stream.function.StreamBridge +import org.springframework.jms.core.JmsTemplate +import org.springframework.kafka.core.KafkaTemplate + +class KotlinOrderPublishers( + private val kafkaTemplate: KafkaTemplate, + private val rabbitTemplate: RabbitTemplate, + private val jmsTemplate: JmsTemplate, + private val streamBridge: StreamBridge, +) { + @Value("\${app.messaging.orders-topic}") + private lateinit var ordersTopic: String + + fun publishLiteralDestination(payload: String) { + kafkaTemplate.send("orders", payload) + } + + fun publishConstantDestination(payload: String) { + this.kafkaTemplate.send(Destinations.SHIPMENTS, payload) + } + + fun publishConfiguredDestination(payload: String) { + kafkaTemplate.send(ordersTopic, payload) + } + + fun publishToExchange(exchange: String, routingKey: String, payload: String) { + rabbitTemplate.convertAndSend(exchange, routingKey, payload) + } + + fun publishToQueue(payload: String) { + jmsTemplate.convertAndSend("queue.orders", payload) + } + + fun publishToBinding(payload: String) { + streamBridge.send("orders-out-0", payload) + } +} diff --git a/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts b/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts index 6e239675e..86e4fb5d2 100644 --- a/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts +++ b/gitnexus/test/integration/spring-non-http-handlers-pipeline.test.ts @@ -4,6 +4,14 @@ 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 { + getJavaSpringMessageProducerFacts, + getJavaSpringNonHttpHandlerFacts, +} from '../../src/core/ingestion/languages/java/capture-side-channel.js'; +import { + getKotlinSpringMessageProducerFacts, + getKotlinSpringNonHttpHandlerFacts, +} from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; import { loadParseCache, PARSE_CACHE_VERSION, @@ -95,6 +103,70 @@ describe('Spring non-HTTP handler entry points (#2417)', () => { expect(method.properties.astFrameworkReason).toBe('jaxrs-annotation'); }); + /** Capture facts are keyed by the same file path the graph records. */ + function filePathOf(methodName: string, fileSuffix: string): string { + return String(methodNamed(methodName, fileSuffix).properties.filePath); + } + + it('carries Java annotation arguments across the worker boundary', () => { + const facts = getJavaSpringNonHttpHandlerFacts( + filePathOf('consumeOrder', 'MessageConsumers.java'), + ); + expect(facts.flatMap((fact) => fact.annotations.map((a) => [a.name, a.args]))).toEqual( + expect.arrayContaining([['KafkaListener', [{ name: 'topics', text: '"orders"' }]]]), + ); + }); + + it('carries Kotlin annotation arguments across the worker boundary', () => { + const facts = getKotlinSpringNonHttpHandlerFacts( + filePathOf('consumeEnumMessage', 'SingletonHandlers.kt'), + ); + expect(facts.flatMap((fact) => fact.annotations.map((a) => [a.name, a.args]))).toEqual( + expect.arrayContaining([ + ['KafkaListener', [{ name: 'topics', text: '["enum-orders"]' }]], + // A marker annotation ships no argument list at all. + ['EventListener', undefined], + ]), + ); + }); + + it.each([ + [ + 'OrderPublishers.java', + 'publishLiteralDestination', + getJavaSpringMessageProducerFacts, + [ + '"orders"', + 'SHIPMENTS_TOPIC', + 'ordersTopic', + 'exchange', + '"queue.orders"', + '"orders-out-0"', + ], + ], + [ + 'OrderPublishers.kt', + 'publishLiteralDestination', + getKotlinSpringMessageProducerFacts, + [ + '"orders"', + 'Destinations.SHIPMENTS', + 'ordersTopic', + 'exchange', + '"queue.orders"', + '"orders-out-0"', + ], + ], + ])('carries %s producer facts across the worker boundary', (file, method, getFacts, expected) => { + const facts = getFacts(filePathOf(method, file)); + expect(facts.map((fact) => fact.template)).toEqual( + expect.arrayContaining(['kafka', 'rabbit', 'jms', 'stream-bridge']), + ); + // A literal, a constant, and a configuration-backed name all yield a fact, + // and none of them is resolved at capture time. + expect(facts.map((fact) => fact.args?.[0]?.text)).toEqual(expected); + }); + it('does not model non-HTTP framework handlers as HTTP routes', () => { const routes: GraphNode[] = []; result.graph.forEachNode((node) => { @@ -134,6 +206,61 @@ describe('Spring non-HTTP handler entry points (#2417)', () => { }); }); +interface MessagingRow { + readonly kind: 'producer' | 'annotation'; + readonly file: string; + readonly detail: string; + readonly args?: readonly { name?: string; text: string }[]; +} + +/** + * Every Spring messaging fact the capture side channels hold for a pipeline + * run, keyed by the file paths that run actually produced. + * + * Deliberately covers BOTH fact families. A snapshot of only the new producer + * list would still match after a change that stopped capturing the handler + * annotations it sits next to. + */ +function messagingSnapshot(pipeline: PipelineResult): MessagingRow[] { + const filePaths = new Set(); + for (const node of pipeline.graph.iterNodes()) { + const filePath = node.properties.filePath; + if (typeof filePath === 'string') filePaths.add(filePath); + } + const rows: MessagingRow[] = []; + for (const filePath of [...filePaths].sort()) { + const java = filePath.endsWith('.java'); + const kotlin = filePath.endsWith('.kt'); + if (!java && !kotlin) continue; + const file = path.basename(filePath); + const producers = java + ? getJavaSpringMessageProducerFacts(filePath) + : getKotlinSpringMessageProducerFacts(filePath); + for (const producer of producers) { + rows.push({ + kind: 'producer', + file, + detail: `${producer.template} ${producer.receiverName}.${producer.methodName}`, + ...(producer.args === undefined ? {} : { args: producer.args }), + }); + } + const handlers = java + ? getJavaSpringNonHttpHandlerFacts(filePath) + : getKotlinSpringNonHttpHandlerFacts(filePath); + for (const handler of handlers) { + for (const annotation of handler.annotations) { + rows.push({ + kind: 'annotation', + file, + detail: annotation.name, + ...(annotation.args === undefined ? {} : { args: annotation.args }), + }); + } + } + } + return rows; +} + 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-')); @@ -152,6 +279,10 @@ describe('Spring non-HTTP handler durable warm parse cache (#2417)', () => { parseCache: coldCache, }); expect(cold.usedWorkerPool).toBe(true); + // The side-channel stores are keyed by file path and hold only the most + // recent run, so the cold snapshot has to be taken before the warm run + // overwrites them. + const coldMessaging = messagingSnapshot(cold); pruneCache(coldCache, coldCache.usedKeys); const savedKeys = await saveParseCache(storage, coldCache); @@ -171,6 +302,54 @@ describe('Spring non-HTTP handler durable warm parse cache (#2417)', () => { }); expect(warm.usedWorkerPool).toBe(false); + // A warm run replays worker output verbatim, so the messaging facts have + // to survive the ParsedFile round trip exactly. + expect(messagingSnapshot(warm)).toEqual(coldMessaging); + + // Equality alone would also hold for two empty snapshots, and a snapshot + // pooled across languages would let a Java regression hide behind Kotlin + // rows, so each family is pinned per language and by content. + const rowsIn = (file: string, kind: MessagingRow['kind']): MessagingRow[] => + coldMessaging.filter((row) => row.file === file && row.kind === kind); + expect(rowsIn('OrderPublishers.java', 'producer').map((row) => row.detail)).toEqual([ + 'kafka kafkaTemplate.send', + 'kafka this.kafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'rabbit rabbitTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'stream-bridge streamBridge.send', + ]); + expect(rowsIn('OrderPublishers.kt', 'producer').map((row) => row.detail)).toEqual([ + 'kafka kafkaTemplate.send', + 'kafka this.kafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'rabbit rabbitTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'stream-bridge streamBridge.send', + ]); + // A literal, a constant, and a configuration-backed name all survive the + // round trip unresolved. + expect(rowsIn('OrderPublishers.java', 'producer').map((row) => row.args?.[0]?.text)).toEqual([ + '"orders"', + 'SHIPMENTS_TOPIC', + 'ordersTopic', + 'exchange', + '"queue.orders"', + '"orders-out-0"', + ]); + // Both languages must still carry annotation arguments, and an annotation + // written without an argument list stays distinguishable from one written + // with an empty list. + expect( + rowsIn('MessageConsumers.java', 'annotation').some((row) => row.args !== undefined), + ).toBe(true); + expect( + rowsIn('SingletonHandlers.kt', 'annotation').some((row) => row.args !== undefined), + ).toBe(true); + expect( + rowsIn('SingletonHandlers.kt', 'annotation').some((row) => row.args === undefined), + ).toBe(true); + const project = (pipeline: PipelineResult) => [...pipeline.graph.iterNodes()] .filter( diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 6ee5651f1..6f73c2dba 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -244,12 +244,12 @@ describe('PARSE_CACHE_VERSION', () => { // collided, because each re-checked once and neither re-checked after the // other moved — which is why the rule is re-applied AT MERGE, not when the // number is picked. - it('pins SCHEMA_BUMP to 88 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(88); + it('pins SCHEMA_BUMP to 89 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(89); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, + 82, 83, 84, 85, 86, 87, 88, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/spring-argument-fact-capture.test.ts b/gitnexus/test/unit/spring-argument-fact-capture.test.ts new file mode 100644 index 000000000..9049a8921 --- /dev/null +++ b/gitnexus/test/unit/spring-argument-fact-capture.test.ts @@ -0,0 +1,742 @@ +import { describe, expect, it } from 'vitest'; +import type { SpringNonHttpHandlerAnnotationFact } from '../../src/core/ingestion/frameworks/spring/non-http-handlers.js'; +import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; + +const JAVA_FILE = 'src/HandlerArguments.java'; +const KOTLIN_FILE = 'src/HandlerArguments.kt'; + +/** + * One `{ annotationName -> annotation }` view per captured callable, in capture + * order. Handler facts are keyed by owner range, which is noisy to assert on; + * the annotations themselves are what this suite is about. + */ +function javaHandlerAnnotations(code: string): SpringNonHttpHandlerAnnotationFact[][] { + emitJavaScopeCaptures(code, JAVA_FILE); + const facts = collectJavaCaptureSideChannel(JAVA_FILE)?.springNonHttpHandlerFacts ?? []; + return facts.map((fact) => [...fact.annotations]); +} + +function kotlinHandlerAnnotations(code: string): SpringNonHttpHandlerAnnotationFact[][] { + emitKotlinScopeCaptures(code, KOTLIN_FILE); + const facts = collectKotlinCaptureSideChannel(KOTLIN_FILE)?.springNonHttpHandlerFacts ?? []; + return facts.map((fact) => [...fact.annotations]); +} + +function only(annotations: SpringNonHttpHandlerAnnotationFact[][], name: string) { + const matches = annotations.flat().filter((annotation) => annotation.name === name); + if (matches.length !== 1) { + throw new Error(`expected exactly one @${name}, captured ${matches.length}`); + } + return matches[0]!; +} + +describe('Java non-HTTP handler annotation arguments', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + import com.example.handlers.support.Destinations; + import com.xxl.job.core.handler.annotation.XxlJob; + import org.springframework.amqp.rabbit.annotation.RabbitListener; + import org.springframework.jms.annotation.JmsListener; + import org.springframework.kafka.annotation.KafkaListener; + import org.springframework.scheduling.annotation.Scheduled; + + public class OrderHandlers { + private static final String JOB_NAME = "constantJobHandler"; + + @Scheduled + public void marker() {} + + @Scheduled() + public void emptyArgumentList() {} + + @KafkaListener(topics = "orders", groupId = "order-consumers") + public void namedArguments(String payload) {} + + @KafkaListener(topics = {"orders", Destinations.SHIPMENTS}) + public void arrayArgument(String payload) {} + + @RabbitListener(queues = Destinations.PAYMENTS) + public void constantArgument(String payload) {} + + @JmsListener(destination = "\${app.messaging.jms-destination}") + public void configuredArgument(String payload) {} + + @XxlJob("literalJobHandler") + public void positionalLiteral() {} + + @XxlJob(JOB_NAME) + public void positionalConstant() {} + + @Scheduled(/* every minute */ cron = "0 * * * * *") + public void commentedArgument() {} + } + `); + + it('omits the argument list for a marker annotation but keeps an empty one', () => { + const marker = annotations[0]?.[0]; + const empty = annotations[1]?.[0]; + expect(marker?.name).toBe('Scheduled'); + expect(marker && 'args' in marker).toBe(false); + expect(empty?.name).toBe('Scheduled'); + expect(empty?.args).toEqual([]); + }); + + it('keeps annotation argument names, so topics and queues stay distinguishable', () => { + expect(annotations[2]?.[0]?.args).toEqual([ + { name: 'topics', text: '"orders"' }, + { name: 'groupId', text: '"order-consumers"' }, + ]); + expect(only(annotations, 'RabbitListener').args).toEqual([ + { name: 'queues', text: 'Destinations.PAYMENTS' }, + ]); + expect(only(annotations, 'JmsListener').args).toEqual([ + { name: 'destination', text: '"${app.messaging.jms-destination}"' }, + ]); + }); + + it('captures a single-element annotation argument positionally', () => { + const positional = annotations.flat().filter((annotation) => annotation.name === 'XxlJob'); + expect(positional.map((annotation) => annotation.args)).toEqual([ + [{ text: '"literalJobHandler"' }], + [{ text: 'JOB_NAME' }], + ]); + }); + + it('resolves nothing: constants, placeholders, and arrays stay as written', () => { + expect(annotations[3]?.[0]?.args).toEqual([ + { name: 'topics', text: '{"orders", Destinations.SHIPMENTS}' }, + ]); + const texts = annotations.flat().flatMap((annotation) => annotation.args ?? []); + expect(texts.some((argument) => argument.text === 'Destinations.PAYMENTS')).toBe(true); + expect(texts.some((argument) => argument.text === 'JOB_NAME')).toBe(true); + // A resolver would have turned these into "shipments" / "constantJobHandler". + expect(texts.some((argument) => argument.text.includes('shipments'))).toBe(false); + expect(texts.some((argument) => argument.text === '"constantJobHandler"')).toBe(false); + }); + + it('skips comments interleaved with annotation arguments', () => { + const commented = annotations + .flat() + .filter((annotation) => annotation.name === 'Scheduled') + .at(-1); + expect(commented?.args).toEqual([{ name: 'cron', text: '"0 * * * * *"' }]); + }); +}); + +describe('Kotlin non-HTTP handler annotation arguments', () => { + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import com.example.handlers.support.Destinations + import com.xxl.job.core.handler.annotation.XxlJob + import org.springframework.context.event.EventListener + import org.springframework.jms.annotation.JmsListener + import org.springframework.kafka.annotation.KafkaListener + import org.springframework.scheduling.annotation.Scheduled + + class OrderHandlers { + @Scheduled + fun marker() {} + + @Scheduled() + fun emptyArgumentList() {} + + @KafkaListener(topics = ["orders", "shipments"], groupId = "order-consumers") + fun collectionArgument(payload: String) {} + + @JmsListener(destination = "\\\${app.messaging.jms-destination}") + fun configuredArgument(payload: String) {} + + @XxlJob(Destinations.JOB_NAME) + fun positionalConstant() {} + + @get:Scheduled(cron = "0 * * * * *") + fun targetedGetter(): String = "value" + + @[EventListener Scheduled(cron = "0 * * * * *")] + fun multiAnnotated() {} + + @[Scheduled(cron = "0 0 * * * *") EventListener] + fun multiAnnotatedReversed() {} + } + `); + + it('omits the argument list for a marker annotation but keeps an empty one', () => { + const marker = annotations[0]?.[0]; + const empty = annotations[1]?.[0]; + expect(marker?.name).toBe('Scheduled'); + expect(marker && 'args' in marker).toBe(false); + expect(empty?.args).toEqual([]); + }); + + it('keeps argument names and the collection literal as written', () => { + expect(annotations[2]?.[0]?.args).toEqual([ + { name: 'topics', text: '["orders", "shipments"]' }, + { name: 'groupId', text: '"order-consumers"' }, + ]); + expect(annotations[3]?.[0]?.args).toEqual([ + { name: 'destination', text: '"\\${app.messaging.jms-destination}"' }, + ]); + expect(annotations[4]?.[0]?.args).toEqual([{ text: 'Destinations.JOB_NAME' }]); + }); + + it('keeps arguments alongside a use-site target', () => { + const targeted = annotations[5]?.[0]; + expect(targeted?.useSiteTarget).toBe('get'); + expect(targeted?.args).toEqual([{ name: 'cron', text: '"0 * * * * *"' }]); + }); + + it('pairs multi-annotation arguments with the annotation that owns them', () => { + // `@[EventListener Scheduled(cron = ...)]` names EventListener; handing it + // Scheduled's cron would invent a schedule the source never wrote. + expect(annotations[6]?.[0]?.name).toBe('EventListener'); + expect(annotations[6]?.[0] && 'args' in annotations[6][0]).toBe(false); + expect(annotations[7]?.[0]?.name).toBe('Scheduled'); + expect(annotations[7]?.[0]?.args).toEqual([{ name: 'cron', text: '"0 0 * * * *"' }]); + }); +}); + +describe('non-HTTP handler capture regressions', () => { + it('still captures every previously recognized Java handler annotation', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + import com.xxl.job.core.handler.annotation.XxlJob; + import org.springframework.context.event.EventListener; + import org.springframework.integration.annotation.ServiceActivator; + import org.springframework.kafka.annotation.KafkaListener; + import org.springframework.scheduling.annotation.Scheduled; + + public class MixedHandlers { + @Override + public String toString() { return "MixedHandlers"; } + + @Scheduled(fixedDelayString = "PT1M") + public void scheduled() {} + + @EventListener + public void event(Object payload) {} + + @KafkaListener(topics = "orders") + public void message(String payload) {} + + @ServiceActivator(inputChannel = "orders") + public void serviceActivator(String payload) {} + + @XxlJob("literalJobHandler") + public void job() {} + } + `); + expect(annotations.map((fact) => fact.map((annotation) => annotation.name))).toEqual([ + ['Scheduled'], + ['EventListener'], + ['KafkaListener'], + ['ServiceActivator'], + ['XxlJob'], + ]); + }); + + it('still captures Kotlin aliases and use-site targets without a name prefilter', () => { + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import org.springframework.context.event.EventListener as SpringEvent + import org.springframework.scheduling.annotation.Scheduled + + class AliasedHandlers { + @SpringEvent + fun aliasedEvent(event: Any) {} + + @setparam:Scheduled + fun targetedReceiver(value: String) {} + } + `); + expect( + annotations.map((fact) => + fact.map((annotation) => [annotation.name, annotation.useSiteTarget]), + ), + ).toEqual([[['SpringEvent', undefined]], [['Scheduled', 'setparam']]]); + }); + + it('leaves Spring DI annotation facts free of argument text', () => { + // Arguments are opt-in; DI captures every annotated member in a repository + // and must not start shipping their argument text worker to main thread. + emitJavaScopeCaptures( + ` + package com.example.beans; + + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.stereotype.Service; + + @Service("orderService") + public class OrderService { + @Autowired + @Qualifier("primaryRepository") + private OrderRepository repository; + } + `, + JAVA_FILE, + ); + const diFacts = collectJavaCaptureSideChannel(JAVA_FILE)?.springDiFacts ?? []; + const diAnnotations = diFacts.flatMap((fact) => [ + ...fact.classAnnotations, + ...fact.injectionSites.flatMap((site) => [...site.annotations]), + ]); + expect(diAnnotations.length).toBeGreaterThan(0); + expect(diAnnotations.every((annotation) => !('args' in annotation))).toBe(true); + }); +}); + +describe('Java annotation argument shapes', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + import org.springframework.amqp.rabbit.annotation.Exchange; + import org.springframework.amqp.rabbit.annotation.Queue; + import org.springframework.amqp.rabbit.annotation.QueueBinding; + import org.springframework.amqp.rabbit.annotation.RabbitListener; + import org.springframework.scheduling.annotation.Scheduled; + import org.springframework.scheduling.annotation.Schedules; + + public class ArgumentShapes { + @org.springframework.kafka.annotation.KafkaListener(topics = "orders") + public void fullyQualifiedAnnotation(String payload) {} + + @Scheduled(cron = "0 * * * * *") + @Scheduled(cron = "0 0 * * * *") + public void repeatedAnnotations() {} + + @Scheduled(/* deliberately empty */) + public void commentOnlyArgumentList() {} + + @Schedules({@Scheduled(cron = "0 * * * * *"), @Scheduled(cron = "0 0 * * * *")}) + public void containerAnnotation() {} + + @RabbitListener(bindings = @QueueBinding(value = @Queue("queue.orders"), exchange = @Exchange("orders.exchange"))) + public void nestedAnnotationArgument(String payload) {} + + @org.springframework.kafka.annotation.KafkaListener(topics = "#{'\${app.topics}'.split(',')}") + public void expressionArgument(String payload) {} + } + `); + + it('reads arguments of an annotation written with its fully qualified name', () => { + const fact = annotations[0]?.[0]; + expect(fact?.name).toBe('org.springframework.kafka.annotation.KafkaListener'); + expect(fact?.args).toEqual([{ name: 'topics', text: '"orders"' }]); + }); + + it('keeps each repeated annotation with its own arguments', () => { + expect(annotations[1]?.map((annotation) => annotation.args)).toEqual([ + [{ name: 'cron', text: '"0 * * * * *"' }], + [{ name: 'cron', text: '"0 0 * * * *"' }], + ]); + }); + + it('treats a comment-only argument list as an empty list, not a missing one', () => { + expect(annotations[2]?.[0]?.args).toEqual([]); + }); + + it('keeps a nested annotation tree as one unresolved argument', () => { + expect(annotations[3]?.[0]?.args).toEqual([ + { text: '{@Scheduled(cron = "0 * * * * *"), @Scheduled(cron = "0 0 * * * *")}' }, + ]); + expect(annotations[4]?.[0]?.args).toEqual([ + { + name: 'bindings', + text: '@QueueBinding(value = @Queue("queue.orders"), exchange = @Exchange("orders.exchange"))', + }, + ]); + }); + + it('keeps a Spring expression as written rather than evaluating it', () => { + expect(annotations[5]?.[0]?.args).toEqual([ + { name: 'topics', text: `"#{'\${app.topics}'.split(',')}"` }, + ]); + }); +}); + +describe('Kotlin annotation argument shapes', () => { + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import org.springframework.kafka.annotation.KafkaListener + import org.springframework.kafka.annotation.KafkaListeners + import org.springframework.scheduling.annotation.Scheduled + + class ArgumentShapes { + @KafkaListeners(KafkaListener(topics = ["orders"]), KafkaListener(topics = ["shipments"])) + fun containerAnnotation(payload: String) {} + + @Scheduled(/* deliberately empty */) + fun commentOnlyArgumentList() {} + + @Scheduled(cron = """0 * * * * *""") + fun rawStringArgument() {} + + @KafkaListener(topics = ["orders"], groupId = "order-consumers",) + fun trailingComma(payload: String) {} + } + `); + + it('keeps each nested annotation of a container as its own raw argument', () => { + expect(annotations[0]?.[0]?.args).toEqual([ + { text: 'KafkaListener(topics = ["orders"])' }, + { text: 'KafkaListener(topics = ["shipments"])' }, + ]); + }); + + it('treats a comment-only argument list as an empty list, not a missing one', () => { + expect(annotations[1]?.[0]?.args).toEqual([]); + }); + + it('keeps a raw string argument with its delimiters', () => { + expect(annotations[2]?.[0]?.args).toEqual([{ name: 'cron', text: '"""0 * * * * *"""' }]); + }); + + it('ignores a trailing comma in the argument list', () => { + expect(annotations[3]?.[0]?.args).toEqual([ + { name: 'topics', text: '["orders"]' }, + { name: 'groupId', text: '"order-consumers"' }, + ]); + }); +}); + +/** + * The destination-bearing annotations are only half the handler family. An + * event listener names its address as an event TYPE and an integration + * endpoint names it as a CHANNEL, so both carry an address in an argument just + * as `topics` and `queues` do, and both must survive capture unresolved. + */ +describe('handler annotations that name an event type or an integration channel', () => { + const javaAnnotations = javaHandlerAnnotations(` + package com.example.handlers; + + import com.example.handlers.support.Channels; + import org.springframework.context.event.EventListener; + import org.springframework.integration.annotation.ServiceActivator; + import org.springframework.transaction.event.TransactionalEventListener; + + public class OrderIntegration { + @EventListener(OrderCreated.class) + public void positionalEventType(OrderCreated event) {} + + @EventListener(classes = {OrderCreated.class, OrderShipped.class}, condition = "#event.urgent") + public void namedEventTypes(Object event) {} + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void afterCommit(Object event) {} + + @ServiceActivator(inputChannel = "orders.in", outputChannel = Channels.OUT) + public void channels(Object message) {} + + @ServiceActivator(inputChannel = "\${app.integration.input-channel}") + public void configuredChannel(Object message) {} + } + `).flat(); + + it('still recognizes every event and integration handler it recognized before', () => { + expect(javaAnnotations.map((annotation) => annotation.name)).toEqual([ + 'EventListener', + 'EventListener', + 'TransactionalEventListener', + 'ServiceActivator', + 'ServiceActivator', + ]); + }); + + it('captures a Java event type positionally and named event types by key', () => { + expect(javaAnnotations[0]?.args).toEqual([{ text: 'OrderCreated.class' }]); + expect(javaAnnotations[1]?.args).toEqual([ + { name: 'classes', text: '{OrderCreated.class, OrderShipped.class}' }, + { name: 'condition', text: '"#event.urgent"' }, + ]); + expect(javaAnnotations[2]?.args).toEqual([ + { name: 'phase', text: 'TransactionPhase.AFTER_COMMIT' }, + ]); + }); + + it('keeps Java integration channels apart by name and leaves them unresolved', () => { + expect(javaAnnotations[3]?.args).toEqual([ + { name: 'inputChannel', text: '"orders.in"' }, + { name: 'outputChannel', text: 'Channels.OUT' }, + ]); + expect(javaAnnotations[4]?.args).toEqual([ + { name: 'inputChannel', text: '"${app.integration.input-channel}"' }, + ]); + }); + + const kotlinAnnotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import com.example.handlers.support.Channels + import com.example.handlers.support.Destinations + import org.springframework.amqp.rabbit.annotation.RabbitListener + import org.springframework.context.event.EventListener + import org.springframework.integration.annotation.ServiceActivator + + class OrderIntegration { + @EventListener(OrderCreated::class) + fun positionalEventType(event: OrderCreated) {} + + @EventListener(classes = [OrderCreated::class], condition = "#event.urgent") + fun namedEventTypes(event: Any) {} + + @ServiceActivator(inputChannel = "orders.in", outputChannel = Channels.OUT) + fun channels(message: Any) {} + + @RabbitListener(queues = ["orders"], containerFactory = "ordersFactory") + fun literalQueue(payload: String) {} + + @RabbitListener(queues = [Destinations.PAYMENTS]) + fun constantQueue(payload: String) {} + + @RabbitListener(queues = ["\\\${app.messaging.queue}"]) + fun configuredQueue(payload: String) {} + } + `).flat(); + + it('still recognizes the same Kotlin handlers once arguments are read', () => { + expect(kotlinAnnotations.map((annotation) => annotation.name)).toEqual([ + 'EventListener', + 'EventListener', + 'ServiceActivator', + 'RabbitListener', + 'RabbitListener', + 'RabbitListener', + ]); + }); + + it('captures Kotlin event types and integration channels', () => { + expect(kotlinAnnotations[0]?.args).toEqual([{ text: 'OrderCreated::class' }]); + expect(kotlinAnnotations[1]?.args).toEqual([ + { name: 'classes', text: '[OrderCreated::class]' }, + { name: 'condition', text: '"#event.urgent"' }, + ]); + expect(kotlinAnnotations[2]?.args).toEqual([ + { name: 'inputChannel', text: '"orders.in"' }, + { name: 'outputChannel', text: 'Channels.OUT' }, + ]); + }); + + it('captures a Kotlin queue written as a literal, a constant, or a placeholder', () => { + expect(kotlinAnnotations.slice(3).map((annotation) => annotation.args)).toEqual([ + [ + { name: 'queues', text: '["orders"]' }, + { name: 'containerFactory', text: '"ordersFactory"' }, + ], + [{ name: 'queues', text: '[Destinations.PAYMENTS]' }], + [{ name: 'queues', text: '["\\${app.messaging.queue}"]' }], + ]); + }); + + it('resolves neither an event type nor a channel at capture time', () => { + const texts = [...javaAnnotations, ...kotlinAnnotations].flatMap( + (annotation) => annotation.args ?? [], + ); + // A resolver would have replaced these with a class, a channel, or a value. + expect(texts.some((argument) => argument.text.includes('Channels.OUT'))).toBe(true); + expect(texts.some((argument) => argument.text.includes('Destinations.PAYMENTS'))).toBe(true); + expect(texts.some((argument) => argument.text === '"payments"')).toBe(false); + expect(texts.some((argument) => argument.text.includes('com.example'))).toBe(false); + }); +}); + +describe('Spring handler annotation argument error recovery', () => { + it('reports no Java arguments for an annotation that did not parse', () => { + // Recovery fills the hole after `groupId =` with a node the author never + // wrote — an empty `{}` array borrowed from the method body below — and the + // resulting fact reads exactly like a real one. Reporting no arguments says + // what is true: there is nothing here a resolver can use. + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + public class Unfinished { + @KafkaListener(topics = "orders", groupId = + public void handle(String payload) {} + + @KafkaListener(topics = "later") + public void later(String payload) {} + } + `); + const captured = annotations.flat().map((annotation) => annotation.args); + expect(captured).toEqual([undefined, [{ name: 'topics', text: '"later"' }]]); + }); + + it('reports no Java arguments for an annotation with an empty assignment', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + public class Unfinished { + @KafkaListener(topics = "orders", groupId = ) + public void handle(String payload) {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([undefined]); + }); + + it('reports no Kotlin arguments for an annotation that did not parse', () => { + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + class Unfinished { + @KafkaListener(topics = ["orders"], groupId = ) + fun handle(payload: String) {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([undefined]); + }); + + it('keeps the three argument states apart from the unreadable one', () => { + // A recovered list collapses into the marker state deliberately, and the + // two states that describe real source stay distinct from each other. + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + public class States { + @Scheduled + public void marker() {} + + @Scheduled() + public void empty() {} + + @Scheduled(cron = "0 * * * * *") + public void configured() {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([ + undefined, + [], + [{ name: 'cron', text: '"0 * * * * *"' }], + ]); + }); +}); + +describe('Spring handler annotation argument spellings', () => { + it('gives one Java annotation argument one spelling however the source wrapped it', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + import com.example.handlers.support.Destinations; + + public class WrappedArguments { + @KafkaListener(topics = Destinations.ORDERS) + public void single(String payload) {} + + @KafkaListener(topics = Destinations + .ORDERS) + public void wrapped(String payload) {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([ + [{ name: 'topics', text: 'Destinations.ORDERS' }], + [{ name: 'topics', text: 'Destinations.ORDERS' }], + ]); + }); + + it('gives one Kotlin annotation argument one spelling however the source wrapped it', () => { + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import com.example.handlers.support.Destinations + + class WrappedArguments { + @KafkaListener(topics = [Destinations.ORDERS]) + fun single(payload: String) {} + + @KafkaListener(topics = [Destinations + .ORDERS]) + fun wrapped(payload: String) {} + + @KafkaListener([Destinations + .ORDERS]) + fun wrappedPositional(payload: String) {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([ + [{ name: 'topics', text: '[Destinations.ORDERS]' }], + [{ name: 'topics', text: '[Destinations.ORDERS]' }], + [{ text: '[Destinations.ORDERS]' }], + ]); + }); + + it('keeps the newlines inside a multi-line string literal argument', () => { + const annotations = javaHandlerAnnotations(` + package com.example.handlers; + + public class LiteralArguments { + @KafkaListener(topics = """ +line-a +.line-b""") + public void handle(String payload) {} + } + `); + expect(annotations.flat().map((annotation) => annotation.args)).toEqual([ + [{ name: 'topics', text: '"""\nline-a\n.line-b"""' }], + ]); + }); +}); + +describe('Kotlin handler annotation argument scope', () => { + it('reads arguments for a handler and leaves an unrelated annotation without them', () => { + // Kotlin captures every annotated callable on purpose, but it does not have + // to read every annotation's arguments to do that. Only annotations that a + // handler callable carries pay for the structured copy. + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import org.springframework.kafka.annotation.KafkaListener + import org.springframework.transaction.annotation.Transactional + + class Mixed { + @Transactional("txManager") + fun notAHandler(payload: String) {} + + @Transactional("txManager") + @KafkaListener(topics = ["orders"]) + fun handler(payload: String) {} + } + `); + expect( + annotations.map((fact) => fact.map((annotation) => [annotation.name, annotation.args])), + ).toEqual([ + [['Transactional', undefined]], + [ + ['Transactional', [{ text: '"txManager"' }]], + ['KafkaListener', [{ name: 'topics', text: '["orders"]' }]], + ], + ]); + }); + + it('reads arguments for a handler annotation reached only through an import alias', () => { + // The alias is why Kotlin cannot prefilter callables by simple name. It is + // not a reason to skip the argument pass: the import header states which FQN + // the local name stands for, so the same relevance question can be answered + // about the imported name and carried back to the alias. + const annotations = kotlinHandlerAnnotations(` + package com.example.handlers + + import org.springframework.context.event.EventListener as SpringEvent + import org.springframework.transaction.annotation.Transactional as Tx + + class AliasedHandlers { + @SpringEvent(condition = "#root.args[0] != null") + fun aliasedEvent(event: Any) {} + + @Tx("txManager") + fun notAHandler(payload: String) {} + } + `); + expect( + annotations.map((fact) => fact.map((annotation) => [annotation.name, annotation.args])), + ).toEqual([ + [['SpringEvent', [{ name: 'condition', text: '"#root.args[0] != null"' }]]], + [['Tx', undefined]], + ]); + }); +}); diff --git a/gitnexus/test/unit/spring-bean-extractor.test.ts b/gitnexus/test/unit/spring-bean-extractor.test.ts index 5d37b137f..5c90f57ef 100644 --- a/gitnexus/test/unit/spring-bean-extractor.test.ts +++ b/gitnexus/test/unit/spring-bean-extractor.test.ts @@ -184,12 +184,14 @@ describe('Kotlin Spring non-HTTP handler syntax capture', () => { .sort((left, right) => left.name.localeCompare(right.name)); expect(annotations).toEqual([ { name: 'EventListener', useSiteTarget: 'receiver' }, - { name: 'KafkaListener' }, + { name: 'KafkaListener', args: [{ name: 'topics', text: '["orders"]' }] }, { name: 'Scheduled' }, { name: 'TransactionalEventListener' }, - { name: 'XxlJob' }, + { name: 'XxlJob', args: [{ text: '"enum-handler"' }] }, ]); for (const annotation of annotations) { + // `text` and `line` describe the DI capture, not the handler; only the + // fields a later resolution phase reads may cross the side channel. expect(annotation).not.toHaveProperty('text'); expect(annotation).not.toHaveProperty('line'); } diff --git a/gitnexus/test/unit/spring-message-producers.test.ts b/gitnexus/test/unit/spring-message-producers.test.ts new file mode 100644 index 000000000..14a15bd61 --- /dev/null +++ b/gitnexus/test/unit/spring-message-producers.test.ts @@ -0,0 +1,1310 @@ +import { describe, expect, it } from 'vitest'; +import type { SpringMessageProducerFact } from '../../src/core/ingestion/frameworks/spring/message-producers.js'; +import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; + +const JAVA_FILE = 'src/OrderPublishers.java'; +const KOTLIN_FILE = 'src/OrderPublishers.kt'; + +function javaProducers(code: string): readonly SpringMessageProducerFact[] { + emitJavaScopeCaptures(code, JAVA_FILE); + return collectJavaCaptureSideChannel(JAVA_FILE)?.springMessageProducerFacts ?? []; +} + +function kotlinProducers(code: string): readonly SpringMessageProducerFact[] { + emitKotlinScopeCaptures(code, KOTLIN_FILE); + return collectKotlinCaptureSideChannel(KOTLIN_FILE)?.springMessageProducerFacts ?? []; +} + +const signature = (fact: SpringMessageProducerFact) => + `${fact.template} ${fact.receiverName}.${fact.methodName}`; + +const destination = (fact: SpringMessageProducerFact) => fact.args?.[0]?.text; + +describe('Java Spring messaging producers', () => { + const facts = javaProducers(` + package com.example.messaging; + + import com.example.messaging.support.Destinations; + import org.springframework.beans.factory.annotation.Value; + import org.springframework.core.env.Environment; + import org.springframework.kafka.core.KafkaTemplate; + + public class OrderPublishers { + private final KafkaTemplate kafkaTemplate; + + @Value("\${app.messaging.orders-topic}") + private String ordersTopic; + + public void literalDestination(String payload) { + kafkaTemplate.send("orders", payload); + } + + public void constantDestination(String payload) { + this.kafkaTemplate.send(Destinations.ORDERS, payload); + } + + public void configuredDestination(String payload) { + kafkaTemplate.send(ordersTopic, payload); + } + + public void configuredDestinationInline(String payload, Environment environment) { + kafkaTemplate.send(environment.getProperty("app.messaging.orders-topic"), payload); + } + + public void rabbitDestination(String exchange, String routingKey, String payload) { + rabbitTemplate.convertAndSend(exchange, routingKey, payload); + } + + public void jmsDestination(String payload) { + jmsTemplate.convertAndSend("queue.orders", payload); + } + + public void streamBridgeDestination(String payload) { + streamBridge.send(Destinations.SHIPMENTS_BINDING, payload); + } + } + `); + + it('recognizes every supported template and method pair', () => { + expect(facts.map(signature)).toEqual([ + 'kafka kafkaTemplate.send', + 'kafka this.kafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'rabbit rabbitTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'stream-bridge streamBridge.send', + ]); + }); + + it('produces a fact for a literal, a constant, and a configuration key alike', () => { + expect(facts.slice(0, 4).map(destination)).toEqual([ + '"orders"', + 'Destinations.ORDERS', + 'ordersTopic', + 'environment.getProperty("app.messaging.orders-topic")', + ]); + }); + + it('captures every call argument positionally, since Java has no named ones', () => { + expect(facts[4]?.args).toEqual([ + { text: 'exchange' }, + { text: 'routingKey' }, + { text: 'payload' }, + ]); + expect(facts.flatMap((fact) => fact.args ?? []).every((arg) => !('name' in arg))).toBe(true); + }); +}); + +describe('Java Spring messaging producer receivers', () => { + const facts = javaProducers(` + package com.example.messaging; + + public class ReceiverShapes { + public void prefixedReceiver(String payload) { + orderKafkaTemplate.send("orders", payload); + } + + public void deepReceiver(String payload) { + outer.inner.kafkaTemplate.send("orders", payload); + } + + public void untypedReceiver(String payload) { + template.send("orders", payload); + } + + public void mapReceiver(String payload) { + templates.get("orders").send("orders", payload); + } + + public void factoryReceiver(String payload) { + getTemplate().send("orders", payload); + } + + public void wrongMethodForKafka(String payload) { + kafkaTemplate.convertAndSend("orders", payload); + } + + public void wrongMethodForRabbit(String payload) { + rabbitTemplate.send("orders", payload); + } + } + `); + + it('matches a receiver whose simple name contains the template type name', () => { + expect(facts.map((fact) => fact.receiverName)).toEqual([ + 'orderKafkaTemplate', + 'outer.inner.kafkaTemplate', + ]); + }); + + it('does not guess a broker for an untyped, indexed, or returned receiver', () => { + const receivers = facts.map((fact) => fact.receiverName); + expect(receivers).not.toContain('template'); + expect(receivers.some((name) => name.includes('templates.get'))).toBe(false); + expect(receivers.some((name) => name.includes('getTemplate'))).toBe(false); + }); + + it('requires the method that belongs to the template, not any send-like name', () => { + expect(facts.map((fact) => fact.methodName)).toEqual(['send', 'send']); + }); +}); + +describe('Java Spring messaging producer owners', () => { + it('attributes a publish to its nearest enclosing callable exactly once', () => { + const facts = javaProducers(` + package com.example.messaging; + + import java.util.List; + + public class Owners { + public Owners(String payload) { + kafkaTemplate.send("constructor-orders", payload); + } + + public void insideLambda(List payloads) { + payloads.forEach(payload -> kafkaTemplate.send("lambda-orders", payload)); + } + + public void insideAnonymousClass() { + Runnable task = new Runnable() { + @Override + public void run() { + kafkaTemplate.send("anonymous-orders", "payload"); + } + }; + task.run(); + } + + static class Nested { + void nestedPublish(String payload) { + kafkaTemplate.send("nested-orders", payload); + } + } + } + `); + expect(facts.map(destination)).toEqual([ + '"constructor-orders"', + '"lambda-orders"', + '"anonymous-orders"', + '"nested-orders"', + ]); + expect(new Set(facts.map((fact) => fact.ownerScopeId)).size).toBe(4); + // A lambda body belongs to the method that declares it, while an anonymous + // class body belongs to its own `run` method (line 17 starts at @Override). + expect(facts[1]?.ownerRange.startLine).toBe(11); + expect(facts[2]?.ownerRange.startLine).toBe(17); + }); + + it('drops a publish that has no callable owner', () => { + const facts = javaProducers(` + package com.example.messaging; + + public class NoCallableOwner { + private static final Object WARMUP = kafkaTemplate.send("field-orders", "payload"); + + static { + kafkaTemplate.send("static-initializer-orders", "payload"); + } + } + `); + expect(facts).toEqual([]); + }); +}); + +// Named arguments are illegal when the callee is a Java method — parameter +// names are not guaranteed in bytecode — so every named-argument example here +// publishes through a template DECLARED IN KOTLIN. `KotlinKafkaTemplate` is +// matched by the same receiver-name rule as the Spring class and keeps the +// examples compilable Kotlin rather than a shape no source could take. +describe('Kotlin Spring messaging producers', () => { + const facts = kotlinProducers(` + package com.example.messaging + + import com.example.messaging.support.Destinations + import org.springframework.kafka.core.KafkaTemplate + + class KotlinKafkaTemplate { + fun send(topic: String, data: String) {} + } + + class OrderPublishers(private val kafkaTemplate: KafkaTemplate) { + private val kotlinKafkaTemplate = KotlinKafkaTemplate() + private lateinit var ordersTopic: String + + fun literalDestination(payload: String) { + kafkaTemplate.send("orders", payload) + } + + fun constantDestination(payload: String) { + this.kafkaTemplate.send(Destinations.ORDERS, payload) + } + + fun configuredDestination(payload: String) { + kafkaTemplate.send(ordersTopic, payload) + } + + fun namedArguments(payload: String) { + kotlinKafkaTemplate.send(topic = Destinations.ORDERS, data = payload) + } + + fun safeCallReceiver(payload: String) { + kafkaTemplate?.send("orders", payload) + } + + fun rabbitDestination(exchange: String, routingKey: String, payload: String) { + rabbitTemplate.convertAndSend(exchange, routingKey, payload) + } + + fun jmsDestination(payload: String) { + jmsTemplate.convertAndSend("queue.orders", payload) + } + + fun spreadArgument(args: Array) { + streamBridge.send(*args) + } + } + `); + + it('recognizes every supported template and method pair', () => { + expect(facts.map(signature)).toEqual([ + 'kafka kafkaTemplate.send', + 'kafka this.kafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'kafka kotlinKafkaTemplate.send', + 'kafka kafkaTemplate.send', + 'rabbit rabbitTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'stream-bridge streamBridge.send', + ]); + }); + + it('produces a fact for a literal, a constant, and a configuration-backed name alike', () => { + expect(facts.slice(0, 3).map(destination)).toEqual([ + '"orders"', + 'Destinations.ORDERS', + 'ordersTopic', + ]); + }); + + it('keeps Kotlin named call arguments and spreads as written', () => { + expect(facts[3]?.args).toEqual([ + { name: 'topic', text: 'Destinations.ORDERS' }, + { name: 'data', text: 'payload' }, + ]); + expect(facts[7]?.args).toEqual([{ text: '*args' }]); + }); + + it('reads the receiver structurally, so a safe call is still a publish', () => { + expect(facts[4]?.receiverName).toBe('kafkaTemplate'); + expect(destination(facts[4]!)).toBe('"orders"'); + }); +}); + +describe('Kotlin Spring messaging producer argument lists', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class ArgumentLists { + fun trailingLambdaOnly() { + kafkaTemplate.send { } + } + + fun emptyArgumentList() { + kafkaTemplate.send() + } + } + `); + + it('distinguishes a missing argument list from an empty one', () => { + expect(facts).toHaveLength(2); + expect('args' in facts[0]!).toBe(false); + expect(facts[1]?.args).toEqual([]); + }); +}); + +describe('Kotlin Spring messaging producer owners', () => { + it('attributes publishes in companions, objects, lambdas, and top-level functions', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class Owners { + private val warmup = kafkaTemplate.send("property-orders", "payload") + + init { + kafkaTemplate.send("init-orders", "payload") + } + + fun insideLambda(payloads: List) { + payloads.forEach { payload -> kafkaTemplate.send("lambda-orders", payload) } + } + + companion object { + fun companionPublish(payload: String) { + kafkaTemplate.send("companion-orders", payload) + } + } + } + + object Singleton { + fun objectPublish(payload: String) { + kafkaTemplate.send("object-orders", payload) + } + } + + fun topLevelPublish(payload: String) { + kafkaTemplate.send("top-level-orders", payload) + } + `); + // The property initializer and the init block have no callable of their own. + expect(facts.map(destination)).toEqual([ + '"lambda-orders"', + '"companion-orders"', + '"object-orders"', + '"top-level-orders"', + ]); + expect(new Set(facts.map((fact) => fact.ownerScopeId)).size).toBe(4); + }); +}); + +describe('Spring messaging producer capture regressions', () => { + it('leaves the side channel untouched for a file that publishes nothing', () => { + const sideChannel = (() => { + emitJavaScopeCaptures( + ` + package com.example.messaging; + + public class Quiet { + public void run() { + logger.send("orders", "payload"); + } + } + `, + JAVA_FILE, + ); + return collectJavaCaptureSideChannel(JAVA_FILE); + })(); + expect(sideChannel === undefined || !('springMessageProducerFacts' in sideChannel)).toBe(true); + }); + + it('still captures programmatic bean lookups from the same member-call visit', () => { + emitJavaScopeCaptures( + ` + package com.example.messaging; + + public class Lookups { + public void run() { + kafkaTemplate.send("orders", "payload"); + OrderService service = SpringContextUtil.getBean(OrderService.class); + service.handle(); + } + } + `, + JAVA_FILE, + ); + const sideChannel = collectJavaCaptureSideChannel(JAVA_FILE); + expect(sideChannel?.springMessageProducerFacts).toHaveLength(1); + expect(sideChannel?.springDynamicLookupFacts?.map((fact) => fact.targetTypeName)).toEqual([ + 'OrderService', + ]); + }); +}); + +describe('Spring messaging producer receiver spellings', () => { + it('captures the inner call of the synchronous send idiom', () => { + const facts = javaProducers(` + package com.example.messaging; + + public class Chained { + public void awaited(String payload) throws Exception { + kafkaTemplate.send("orders", payload).get(); + } + + public void withCallback(String payload) { + kafkaTemplate.send("orders", payload).addCallback(ok -> {}, error -> {}); + } + } + `); + expect(facts.map(destination)).toEqual(['"orders"', '"orders"']); + }); + + it('joins a receiver chain that the source wrapped across lines', () => { + const java = javaProducers(` + package com.example.messaging; + + public class Wrapped { + public void publish(String payload) { + outer + .inner + .kafkaTemplate.send("orders", payload); + } + } + `); + const kotlin = kotlinProducers(` + package com.example.messaging + + class Wrapped { + fun publish(payload: String) { + outer + .inner + .kafkaTemplate.send("orders", payload) + } + } + `); + expect(java.map((fact) => fact.receiverName)).toEqual(['outer.inner.kafkaTemplate']); + expect(kotlin.map((fact) => fact.receiverName)).toEqual(['outer.inner.kafkaTemplate']); + }); + + it('leaves single-line spacing and nested literals in the receiver as written', () => { + // Joining a wrapped chain must not reach inside a string literal that the + // receiver expression happens to contain. + const facts = javaProducers(` + package com.example.messaging; + + public class Literals { + public void publish(String payload) { + registry.lookup("a . b").kafkaTemplate.send("orders . v1", payload); + } + } + `); + expect(facts.map((fact) => fact.receiverName)).toEqual([ + 'registry.lookup("a . b").kafkaTemplate', + ]); + expect(facts.map(destination)).toEqual(['"orders . v1"']); + }); + + it('does not attribute a call that names no receiver', () => { + const java = javaProducers(` + package com.example.messaging; + + public class Bare { + public void publish(String payload) { + send("orders", payload); + } + } + `); + const kotlin = kotlinProducers(` + package com.example.messaging + + class Bare { + fun publish(payload: String) { + send("orders", payload) + } + + fun scoped(payload: String) { + with(kafkaTemplate) { + send("orders", payload) + } + } + } + `); + expect(java).toEqual([]); + expect(kotlin).toEqual([]); + }); + + it('does not attribute a cast or parenthesized receiver', () => { + const facts = javaProducers(` + package com.example.messaging; + + public class Casts { + public void publish(Object raw, String payload) { + ((org.springframework.kafka.core.KafkaTemplate) raw) + .send("orders", payload); + } + } + `); + expect(facts).toEqual([]); + }); +}); + +describe('Kotlin Spring messaging producer null assertions', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class Assertions { + fun asserted(payload: String) { + kafkaTemplate!!.send("asserted", payload) + } + + fun doubleAsserted(payload: String) { + kafkaTemplate!!!!.send("double-asserted", payload) + } + + fun assertedInChain(payload: String) { + holder.kafkaTemplate!!.send("chain-tail", payload) + } + + fun assertionInsideChain(payload: String) { + holder!!.kafkaTemplate.send("chain-middle", payload) + } + + fun assertedThenSafeCall(payload: String) { + kafkaTemplate!!?.send("asserted-safe", payload) + } + } + `); + + it('reads through the null assertion to the receiver it asserts', () => { + // `?.` hides its marker in the navigation suffix, but `!!` wraps the + // receiver itself; without unwrapping, every asserted publish is lost. + expect(facts.map((fact) => `${fact.receiverName} ${destination(fact)}`)).toEqual([ + 'kafkaTemplate "asserted"', + 'kafkaTemplate "double-asserted"', + 'holder.kafkaTemplate "chain-tail"', + 'holder!!.kafkaTemplate "chain-middle"', + 'kafkaTemplate "asserted-safe"', + ]); + }); + + it('does not unwrap a postfix operator that is not a null assertion', () => { + const other = kotlinProducers(` + package com.example.messaging + + class NotAssertions { + fun incremented(payload: String) { + counter++.send("orders", payload) + } + + fun assertedUntyped(payload: String) { + template!!.send("orders", payload) + } + + fun assertedFactory(payload: String) { + getTemplate()!!.send("orders", payload) + } + + fun parenthesized(payload: String) { + (kafkaTemplate!!).send("orders", payload) + } + } + `); + expect(other).toEqual([]); + }); +}); + +describe('Kotlin Spring messaging producer call shapes', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class KotlinKafkaTemplate { + fun send(topic: String, data: String) {} + } + + class CallShapes { + private val kotlinKafkaTemplate = KotlinKafkaTemplate() + + fun argumentsAndTrailingLambda(payload: String) { + kafkaTemplate.send("orders", payload) { result -> println(result) } + } + + fun namedArgumentHoldingComparison(payload: String, flag: Boolean) { + kotlinKafkaTemplate.send(topic = if (flag == true) "a" else "b", data = payload) + } + + fun trailingComma(payload: String) { + kafkaTemplate.send( + "orders", + payload, + ) + } + } + `); + + it('keeps the argument list of a call that also passes a trailing lambda', () => { + expect(facts[0]?.args).toEqual([{ text: '"orders"' }, { text: 'payload' }]); + }); + + it('does not mistake a comparison inside a named argument for a second argument', () => { + expect(facts[1]?.args).toEqual([ + { name: 'topic', text: 'if (flag == true) "a" else "b"' }, + { name: 'data', text: 'payload' }, + ]); + }); + + it('ignores a trailing comma in the argument list', () => { + expect(facts[2]?.args).toEqual([{ text: '"orders"' }, { text: 'payload' }]); + }); +}); + +describe('Spring messaging producer side-channel transport', () => { + it('leaves the Kotlin side channel free of producer facts for a quiet file', () => { + emitKotlinScopeCaptures( + ` + package com.example.messaging + + class Quiet { + fun run() { + logger.send("orders", "payload") + } + } + `, + KOTLIN_FILE, + ); + const sideChannel = collectKotlinCaptureSideChannel(KOTLIN_FILE); + expect(sideChannel === undefined || !('springMessageProducerFacts' in sideChannel)).toBe(true); + }); + + it('carries producer facts and handler arguments through a JSON round trip', () => { + // The worker ships the side channel to the main thread as JSON; a fact + // shape that does not survive that trip is invisible to every later phase. + emitJavaScopeCaptures( + ` + package com.example.messaging; + + import org.springframework.kafka.annotation.KafkaListener; + + public class RoundTrip { + @KafkaListener(topics = "orders") + public void consume(String payload) {} + + public void publish(String payload) { + kafkaTemplate.send("orders", payload); + } + } + `, + JAVA_FILE, + ); + const collected = collectJavaCaptureSideChannel(JAVA_FILE); + const restored = JSON.parse(JSON.stringify(collected)) as typeof collected; + expect(restored?.springMessageProducerFacts).toEqual(collected?.springMessageProducerFacts); + expect(restored?.springMessageProducerFacts?.[0]?.args).toEqual([ + { text: '"orders"' }, + { text: 'payload' }, + ]); + expect(restored?.springNonHttpHandlerFacts?.[0]?.annotations[0]?.args).toEqual([ + { name: 'topics', text: '"orders"' }, + ]); + }); +}); + +/** + * Kafka is the template whose destination shapes are covered above. The other + * three carry the same burden: a destination is written as a literal, as a + * constant that lives in another file, or as a name bound from configuration, + * and capture must produce a fact for all three without preferring any. + */ +describe('Spring messaging producer destination kinds per template', () => { + const JAVA_SOURCE = ` + package com.example.messaging; + + import com.example.messaging.support.Destinations; + import org.springframework.amqp.rabbit.core.RabbitTemplate; + import org.springframework.cloud.stream.function.StreamBridge; + import org.springframework.jms.core.JmsTemplate; + + public class OrderPublishers { + private final RabbitTemplate rabbitTemplate; + private final JmsTemplate jmsTemplate; + private final StreamBridge streamBridge; + + @Value("\${app.messaging.orders-exchange}") + private String ordersExchange; + + public void rabbitLiteral(String payload) { + rabbitTemplate.convertAndSend("orders.exchange", "orders.key", payload); + } + + public void rabbitConstant(String payload) { + rabbitTemplate.convertAndSend(Destinations.EXCHANGE, Destinations.ROUTING_KEY, payload); + } + + public void rabbitConfigured(String payload) { + rabbitTemplate.convertAndSend(ordersExchange, "orders.key", payload); + } + + public void jmsLiteral(String payload) { + jmsTemplate.convertAndSend("queue.orders", payload); + } + + public void jmsConstant(String payload) { + jmsTemplate.convertAndSend(Destinations.QUEUE, payload); + } + + public void jmsConfigured(String payload) { + jmsTemplate.convertAndSend(ordersQueue, payload); + } + + public void bridgeLiteral(String payload) { + streamBridge.send("orders-out-0", payload); + } + + public void bridgeConstant(String payload) { + streamBridge.send(Destinations.ORDERS_BINDING, payload); + } + + public void bridgeConfigured(String payload) { + streamBridge.send(ordersBinding, payload); + } + + public void notAPublish(String payload) { + rabbitTemplate.send("orders.exchange", payload); + jmsTemplate.send("queue.orders", payload); + } + } + `; + + const KOTLIN_SOURCE = ` + package com.example.messaging + + import com.example.messaging.support.Destinations + import org.springframework.amqp.rabbit.core.RabbitTemplate + import org.springframework.cloud.stream.function.StreamBridge + import org.springframework.jms.core.JmsTemplate + + class OrderPublishers( + private val rabbitTemplate: RabbitTemplate, + private val jmsTemplate: JmsTemplate, + private val streamBridge: StreamBridge, + @Value("\\\${app.messaging.orders-exchange}") private val ordersExchange: String, + ) { + fun rabbitLiteral(payload: String) { + rabbitTemplate.convertAndSend("orders.exchange", "orders.key", payload) + } + + fun rabbitConstant(payload: String) { + rabbitTemplate.convertAndSend(Destinations.EXCHANGE, Destinations.ROUTING_KEY, payload) + } + + fun rabbitConfigured(payload: String) { + rabbitTemplate.convertAndSend(ordersExchange, "orders.key", payload) + } + + fun jmsLiteral(payload: String) { + jmsTemplate.convertAndSend("queue.orders", payload) + } + + fun jmsConstant(payload: String) { + jmsTemplate.convertAndSend(Destinations.QUEUE, payload) + } + + fun jmsConfigured(payload: String) { + jmsTemplate.convertAndSend(ordersQueue, payload) + } + + fun bridgeLiteral(payload: String) { + streamBridge.send("orders-out-0", payload) + } + + fun bridgeConstant(payload: String) { + streamBridge.send(Destinations.ORDERS_BINDING, payload) + } + + fun bridgeConfigured(payload: String) { + streamBridge.send(ordersBinding, payload) + } + + fun notAPublish(payload: String) { + rabbitTemplate.send("orders.exchange", payload) + jmsTemplate.send("queue.orders", payload) + } + } + `; + + const EXPECTED_SIGNATURES = [ + 'rabbit rabbitTemplate.convertAndSend', + 'rabbit rabbitTemplate.convertAndSend', + 'rabbit rabbitTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'jms jmsTemplate.convertAndSend', + 'stream-bridge streamBridge.send', + 'stream-bridge streamBridge.send', + 'stream-bridge streamBridge.send', + ]; + + const EXPECTED_DESTINATIONS = [ + '"orders.exchange"', + 'Destinations.EXCHANGE', + 'ordersExchange', + '"queue.orders"', + 'Destinations.QUEUE', + 'ordersQueue', + '"orders-out-0"', + 'Destinations.ORDERS_BINDING', + 'ordersBinding', + ]; + + it('gives Java rabbit, jms, and stream-bridge a fact for all three shapes', () => { + const facts = javaProducers(JAVA_SOURCE); + expect(facts.map(signature)).toEqual(EXPECTED_SIGNATURES); + expect(facts.map(destination)).toEqual(EXPECTED_DESTINATIONS); + }); + + it('gives Kotlin rabbit, jms, and stream-bridge a fact for all three shapes', () => { + const facts = kotlinProducers(KOTLIN_SOURCE); + expect(facts.map(signature)).toEqual(EXPECTED_SIGNATURES); + expect(facts.map(destination)).toEqual(EXPECTED_DESTINATIONS); + }); + + it('leaves a send that does not belong to its template unrecognized', () => { + // `notAPublish` is the last method in both fixtures; the signature lists + // above end at the stream bridge, so `RabbitTemplate.send` and + // `JmsTemplate.send` produced nothing. + const receivers = [...javaProducers(JAVA_SOURCE), ...kotlinProducers(KOTLIN_SOURCE)].map( + (fact) => `${fact.receiverName}.${fact.methodName}`, + ); + expect(receivers).not.toContain('rabbitTemplate.send'); + expect(receivers).not.toContain('jmsTemplate.send'); + }); + + it('resolves no destination while capturing it', () => { + const texts = [...javaProducers(JAVA_SOURCE), ...kotlinProducers(KOTLIN_SOURCE)].flatMap( + (fact) => fact.args ?? [], + ); + // A resolver would have turned the constants and the injected name into + // addresses; capture must still be looking at the source spelling. + expect(texts.some((argument) => argument.text === 'Destinations.QUEUE')).toBe(true); + expect(texts.some((argument) => argument.text === 'ordersExchange')).toBe(true); + expect(texts.some((argument) => argument.text.includes('${app.messaging'))).toBe(false); + }); +}); + +describe('Spring messaging producer receiver decorations', () => { + // Template beans are declared under every naming convention a Java or Kotlin + // codebase uses: a qualifying prefix, a qualifying suffix, a version or index + // tail, and the constant spelling that `static final` fields take. A rule that + // only accepted a decorating PREFIX silently dropped the rest, which are the + // publishes this capture exists to find. + const facts = javaProducers(` + package com.example.messaging; + + public class DecoratedTemplates { + public void suffixed(String payload) { kafkaTemplateDlq.send("a", payload); } + public void constantCase(String payload) { KAFKA_TEMPLATE.send("b", payload); } + public void snakeCase(String payload) { kafka_template.send("c", payload); } + public void versioned(String payload) { kafkaTemplateV2.send("d", payload); } + public void indexed(String payload) { kafkaTemplate2.send("e", payload); } + public void constantBridge(String payload) { STREAM_BRIDGE.send("f", payload); } + public void indexedBridge(String payload) { streamBridge2.send("g", payload); } + public void indexedRabbit(String key, String payload) { + rabbitTemplate1.convertAndSend(key, payload); + } + public void prefixed(String payload) { orderKafkaTemplate.send("h", payload); } + public void plain(String payload) { kafkaTemplate.send("i", payload); } + } + `); + + it('recognizes a template decorated by prefix, suffix, index, or constant case', () => { + expect(facts.map(signature)).toEqual([ + 'kafka kafkaTemplateDlq.send', + 'kafka KAFKA_TEMPLATE.send', + 'kafka kafka_template.send', + 'kafka kafkaTemplateV2.send', + 'kafka kafkaTemplate2.send', + 'stream-bridge STREAM_BRIDGE.send', + 'stream-bridge streamBridge2.send', + 'rabbit rabbitTemplate1.convertAndSend', + 'kafka orderKafkaTemplate.send', + 'kafka kafkaTemplate.send', + ]); + }); + + it('recognizes the same decorations in Kotlin', () => { + const kotlin = kotlinProducers(` + package com.example.messaging + + class DecoratedTemplates { + fun suffixed(payload: String) { kafkaTemplateDlq.send("a", payload) } + fun constantCase(payload: String) { KAFKA_TEMPLATE.send("b", payload) } + fun indexedBridge(payload: String) { streamBridge2.send("c", payload) } + } + `); + expect(kotlin.map(signature)).toEqual([ + 'kafka kafkaTemplateDlq.send', + 'kafka KAFKA_TEMPLATE.send', + 'stream-bridge streamBridge2.send', + ]); + }); + + it('still refuses a receiver that only a type could make a template', () => { + // Widening the name match must not reach any of these: an undecorated + // `template` would attribute every `send` in the repository to Kafka, and + // the rest are not names at all. `config.get("a.kafkaTemplate")` is the + // sharp one — splitting on the last dot lands inside the string literal. + expect( + javaProducers(` + package com.example.messaging; + + public class NotTemplates { + public void bare(String payload) { template.send("t", payload); } + public void lookup(String payload) { templates.get("k").send("t", payload); } + public void factory(String payload) { getTemplate().send("t", payload); } + public void indexed(String payload) { templates["k"].send("t", payload); } + public void configured(String payload) { + config.get("a.kafkaTemplate").send("t", payload); + } + public void unrelated(String payload) { mailer.send("t", payload); } + } + `), + ).toEqual([]); + }); + + it('names no broker for a receiver that matches two templates at once', () => { + // Two signatures share `send` and two share `convertAndSend`, so a + // substring match lets one receiver name satisfy both. The receiver's type + // is never resolved, so nothing ranks one match over the other, and the + // first-listed signature would be published as a definite attribution. + expect( + javaProducers(` + package com.example.messaging; + + public class AmbiguousTemplates { + public void bothSend(String payload) { + streamBridgeKafkaTemplate.send("a", payload); + } + public void bothSendReversed(String payload) { + kafkaTemplateStreamBridge.send("b", payload); + } + public void bothConvertAndSend(String key, String payload) { + rabbitTemplateJmsTemplate.convertAndSend(key, payload); + } + } + `), + ).toEqual([]); + }); + + it('names the broker when only one template matches the receiver', () => { + // The counterpart of the rule above: withholding applies to a genuinely + // ambiguous name, not to a decorated one that happens to be long. Neither + // receiver here contains a second template type name. + expect( + javaProducers(` + package com.example.messaging; + + public class UnambiguousTemplates { + public void bridged(String payload) { orderStreamBridge.send("a", payload); } + public void kafka(String payload) { streamingKafkaTemplate.send("b", payload); } + } + `).map(signature), + ).toEqual(['stream-bridge orderStreamBridge.send', 'kafka streamingKafkaTemplate.send']); + }); + + it('refuses a receiver whose last segment is not a bare identifier', () => { + // The identifier gate, not the name match, is what rejects this: strip it + // and `/*c*/kafkaTemplate` matches the type name and the publish is + // attributed to a receiver spelling that includes a comment. + expect( + javaProducers(` + package com.example.messaging; + + public class Commented { + public void commented(String payload) { + this./*which*/kafkaTemplate.send("orders", payload); + } + } + `), + ).toEqual([]); + }); +}); + +describe('Spring messaging producer error recovery', () => { + it('produces no Java fact when the argument list did not parse', () => { + // Recovery keeps the tree well formed while inventing what it contains: the + // unterminated call below absorbs the next method's source and offers it as + // an argument. A fact whose whole purpose is to name a destination must not + // report one that was never written. + expect( + javaProducers(` + package com.example.messaging; + + public class Unfinished { + public void publish(String payload) { + kafkaTemplate.send(ORDERS_TOPIC, + } + + public void other(String value) { + System.out.println(value); + } + } + `), + ).toEqual([]); + }); + + it('produces no Kotlin fact when the argument list did not parse', () => { + expect( + kotlinProducers(` + package com.example.messaging + + class Unfinished { + fun publish(payload: String) { + kafkaTemplate.send(ORDERS_TOPIC, + } + + fun other(value: String) { println(value) } + } + `), + ).toEqual([]); + }); + + it('still captures a well-formed publish in a file that fails to parse elsewhere', () => { + // Failing closed is scoped to the broken call, not to the file: a watcher + // reparse mid-edit must not blank out the publishes that are still intact. + const facts = javaProducers(` + package com.example.messaging; + + public class PartlyBroken { + public void good(String payload) { + kafkaTemplate.send("orders", payload); + } + + public void broken(String payload) { + kafkaTemplate.send(ORDERS_TOPIC, + } + } + `); + expect(facts.map(destination)).toEqual(['"orders"']); + }); +}); + +describe('Spring messaging producer argument spellings', () => { + it('gives one Java argument one spelling however the source wrapped it', () => { + // The receiver already normalized its wraps; leaving the argument raw made + // the same constant compare unequal to itself, because the text carries the + // ENCLOSING block's indentation and so changes with nesting depth. + const facts = javaProducers(` + package com.example.messaging; + + public class WrappedArguments { + public void single(String payload) { + kafkaTemplate.send(Destinations.ORDERS, payload); + } + + public void wrapped(String payload) { + kafkaTemplate.send(Destinations + .ORDERS, payload); + } + + public void wrappedDeeper(String payload) { + if (payload != null) { + kafkaTemplate.send(Destinations + .ORDERS, payload); + } + } + } + `); + expect(facts.map(destination)).toEqual([ + 'Destinations.ORDERS', + 'Destinations.ORDERS', + 'Destinations.ORDERS', + ]); + }); + + it('gives one Kotlin argument one spelling however the source wrapped it', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class KotlinKafkaTemplate { + fun send(topic: String, data: String) {} + } + + class WrappedArguments { + private val kotlinKafkaTemplate = KotlinKafkaTemplate() + + fun single(payload: String) { + kafkaTemplate.send(Destinations.ORDERS, payload) + } + + fun wrapped(payload: String) { + kotlinKafkaTemplate.send(topic = Destinations + .ORDERS, data = payload) + } + } + `); + expect(facts.map(destination)).toEqual(['Destinations.ORDERS', 'Destinations.ORDERS']); + }); + + it('keeps the newlines inside a multi-line string literal argument', () => { + // The wrap rule may not reach inside a literal: a Java text block or Kotlin + // raw string whose newline sits next to a dot is a different VALUE once the + // newline is removed. + const java = javaProducers(` + package com.example.messaging; + + public class LiteralArguments { + public void publish(String payload) { + kafkaTemplate.send(""" +line-a +.line-b""", payload); + } + } + `); + expect(java.map(destination)).toEqual(['"""\nline-a\n.line-b"""']); + }); + + it('keeps the newlines inside a multi-line literal nested in the receiver', () => { + // The single-line form of this is already pinned above; the doc comment on + // the normalizer promised the same for nested literals, and only the + // single-line case delivered it. + const java = javaProducers(` + package com.example.messaging; + + public class LiteralReceiver { + public void publish(String payload) { + registry.get(""" +line-a +.line-b""").kafkaTemplate.send("orders", payload); + } + } + `); + expect(java.map((fact) => fact.receiverName)).toEqual([ + 'registry.get("""\nline-a\n.line-b""").kafkaTemplate', + ]); + }); + + it('drops a comment between Java arguments without shifting their positions', () => { + // Comments are named children of a Java argument list, so an unfiltered + // read reports three arguments for a two-argument call and moves the + // payload into the destination slot for anything reading by position. + const facts = javaProducers(` + package com.example.messaging; + + public class CommentedArguments { + public void publish(String payload) { + kafkaTemplate.send(/* why */ "orders", payload); + } + } + `); + expect(facts.map((fact) => fact.args)).toEqual([[{ text: '"orders"' }, { text: 'payload' }]]); + }); + + it('reads a Kotlin named argument through a comment between name and value', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class KotlinKafkaTemplate { + fun send(topic: String) {} + } + + class CommentedArguments { + private val kotlinKafkaTemplate = KotlinKafkaTemplate() + + fun publish(payload: String) { + kotlinKafkaTemplate.send(topic /* which */ = "orders") + } + } + `); + expect(facts.map((fact) => fact.args)).toEqual([[{ name: 'topic', text: '"orders"' }]]); + }); + + it('does not read an annotated Kotlin positional argument as a named one', () => { + // `@Suppress("x") "orders"` has the same two-child shape as `name = value`. + // Only the `=` token tells them apart, and inventing a name here would + // hand a consumer an argument key that does not exist. + const facts = kotlinProducers(` + package com.example.messaging + + class AnnotatedArguments { + fun publish(payload: String) { + kafkaTemplate.send(@Suppress("UNCHECKED_CAST") "orders", payload) + } + } + `); + expect(facts.map((fact) => fact.args)).toEqual([ + [{ text: '@Suppress("UNCHECKED_CAST") "orders"' }, { text: 'payload' }], + ]); + }); +}); + +describe('Spring messaging producer owner boundaries', () => { + it('does not attribute a Java publish across a nested type body', () => { + // A publish in the field initializer of a class declared inside a method is + // run when that class is instantiated, which the method may never do. The + // same construct at the top level of a class already yields no fact, and + // the rule has to read the same at both depths. + expect( + javaProducers(` + package com.example.messaging; + + public class Nested { + public void outer(String payload) { + class Local { + private final Object sent = kafkaTemplate.send("local-field", payload); + } + } + + public void anonymous(String payload) { + Runnable task = new Runnable() { + private final Object sent = kafkaTemplate.send("anon-field", payload); + public void run() {} + }; + } + } + `), + ).toEqual([]); + }); + + it('still attributes a Java publish to a method of a nested type', () => { + const facts = javaProducers(` + package com.example.messaging; + + public class Nested { + public void anonymous(String payload) { + Runnable task = new Runnable() { + public void run() { kafkaTemplate.send("anon-method", payload); } + }; + } + + class Inner { + void publish(String payload) { kafkaTemplate.send("inner-method", payload); } + } + } + `); + expect(facts.map(destination)).toEqual(['"anon-method"', '"inner-method"']); + }); + + it('does not attribute a Kotlin publish across a nested class body', () => { + expect( + kotlinProducers(` + package com.example.messaging + + class Nested { + fun outer(payload: String) { + class Local { + val sent = kafkaTemplate.send("local-property", payload) + } + + val task = object : Runnable { + val sent = kafkaTemplate.send("object-property", payload) + override fun run() {} + } + } + } + `), + ).toEqual([]); + }); + + it('still attributes a Kotlin publish to a function of a nested class body', () => { + const facts = kotlinProducers(` + package com.example.messaging + + class Nested { + fun outer(payload: String) { + val task = object : Runnable { + override fun run() { kafkaTemplate.send("object-method", payload) } + } + } + } + `); + expect(facts.map(destination)).toEqual(['"object-method"']); + }); + + it('does not unwrap a Kotlin postfix operator that only looks like an assertion', () => { + // The existing coverage used `counter++`, which the name match rejects on + // its own. Only a receiver that WOULD match makes the unwrap guard the + // reason for the rejection. + expect( + kotlinProducers(` + package com.example.messaging + + class Incremented { + fun publish(payload: String) { + kafkaTemplate++.send("orders", payload) + } + } + `), + ).toEqual([]); + }); +});