From f7a58cf1889c92400af2ffc50cef6d966c4f82fa Mon Sep 17 00:00:00 2001 From: glier Date: Tue, 1 Sep 2026 16:35:19 +0300 Subject: [PATCH] feat(ingestion): capture Spring handler annotation arguments and template publishes (#3128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingestion): capture Spring handler annotation arguments and template publishes Non-HTTP handler recognition already resolves the annotation NAME through imports, aliases, use-site targets and package visibility. What it never captured is the annotation's ARGUMENTS, so the destination a listener binds to was invisible: `@KafkaListener(topics = ...)` and `@RabbitListener(queues = ...)` name it with different attributes, and a producer names it by position. The publishing side was missing entirely, which left every messaging edge one-directional by construction. Consumer side: `SpringNonHttpHandlerAnnotationFact` gains an optional `args?: readonly { name?: string; text: string }[]`. `name` is optional because positional and named arguments are genuinely different shapes, not because it is sometimes unknown. Producer side: `KafkaTemplate.send`, `RabbitTemplate.convertAndSend`, `JmsTemplate.convertAndSend` and `StreamBridge.send` for both languages. Arguments are captured as SYNTAX, never as a resolved address. At capture time imports are not final, a sibling file's constants do not exist yet and configuration has not been read — the same reason annotation-name resolution was deferred. Resolution belongs to a later phase; doing it here would be a layering error that happens to work on simple inputs. The parse cache schema moves 82 -> 83. Both new facts ride the existing worker -> main side channel, which is replayed verbatim from `ParsedFile.captureSideChannel`, so a warm v82 cache would skip the workers and hand back annotation facts with no `args` and an empty producer list. Measured on the fixture app: a warm all-cache-hit run (`usedWorkerPool=false`, `reparsedFileCount=0`) reproduces 6 Java and 7 Kotlin producer facts from the store alone — exactly the state a pre-change cache would have served as zero. Tests cover both languages across literal, constant and configuration-key destinations, and pin the PREVIOUS behaviour too: handlers captured before are still captured, and shapes that must not produce a fact still do not. * test(ingestion): cover the handler and template shapes capture left unpinned Auditing the argument capture against its own definition of done turned up three annotations and three templates that work but that nothing asserts, so a regression in them would land silently. Handler side: `@EventListener` and `@ServiceActivator` were only ever checked for RECOGNITION, never for arguments, in either language, and Kotlin `@RabbitListener` appeared in no argument test at all. Both annotations carry an address just as `topics` and `queues` do — an event listener names it as a type and an integration endpoint names it as a channel — so leaving them unpinned left a third of the handler family covered by nothing. Producer side: Kafka was the only template whose destination was written three ways. Rabbit, JMS and the stream bridge each appeared with a single spelling, so nothing said that a constant or a configuration-bound name produces a fact for them too. The same fixtures pin the negative that `RabbitTemplate.send` and `JmsTemplate.send` stay unrecognized, since only the method that belongs to the template counts. Each test asserts the previous behaviour alongside the new one: the handler is still recognized and still named the same, and only then are its arguments checked. A test that looked at arguments alone would keep passing if recognition itself broke. Verified against the parent commit that this is coverage, not repair: every Java and Kotlin fixture in the suite produces a byte-identical capture side channel on both revisions once arguments and producer facts are set aside. Mutating `StreamBridge` out of the template table, and making Kotlin annotation arguments return nothing, each fail the new tests. Co-Authored-By: Claude Opus 5 (1M context) * fix(ingestion): stop Spring capture from inventing arguments and receivers Five defects in the capture-time Spring messaging facts, all of which put data that is wrong — not data that is missing — into a durable store. Facts built from recovered syntax. After a syntax error tree-sitter keeps parsing by guessing boundaries, so the tree stays well formed while describing text nobody wrote. An unterminated `kafkaTemplate.send(TOPIC,` absorbed the next method's source and offered it as two more arguments; `@KafkaListener(topics = "orders", groupId =` reported a `groupId` whose value was an empty `{}` borrowed from the method body. Both now fail closed: a producer call with an unparsed argument list yields no fact at all, and an annotation with one reports no arguments. Neither carries a state that could mean "published somewhere unreadable", so the choice was between silence and a plausible lie. Arguments were not normalized though the receiver beside them was. The receiver already collapsed a wrapped chain to one spelling; the argument kept its newlines and the ENCLOSING block's indentation, so the same constant compared unequal to itself at two nesting depths, and again in a CRLF checkout. Receiver and argument now share one normalizer. That normalizer damaged multi-line literals. Its doc comment promised to keep the rewrite away from nested string literals, and delivered that only for single-line ones: a Java text block or Kotlin raw string whose newline sat next to a dot lost the newline, changing the value. The normalizer is now literal-aware, which makes the promise true for both. The receiver name match accepted only one decoration. Matching the type name as a suffix recognized `orderKafkaTemplate` and dropped `kafkaTemplateDlq`, `kafkaTemplateV2`, `kafkaTemplate2`, `KAFKA_TEMPLATE`, `kafka_template`, `streamBridge2`, `STREAM_BRIDGE`, and `rabbitTemplate1` — including the `static final` constant spelling, which is exactly the shape this capture exists to find. The name is now folded on `_`/`$` and matched as a substring. The bare-identifier gate still runs first and is what keeps `config.get("a.kafkaTemplate")`, `templates["k"]`, and `getTemplate()` out. Ownership was attributed one level too deep. With no boundary types the ancestor walk passed through a nested type body, so a publish in the field initializer of a class declared inside a method was attributed to that method, which may never run it. The identical construct at the top level of a class already yielded no fact; a type body is now a boundary so the rule reads the same at every depth, while a publish in a METHOD of a nested or anonymous type is still attributed to that method. Co-Authored-By: Claude Opus 5 (1M context) * perf(ingestion): read Kotlin handler annotation arguments on evidence Kotlin asked for annotation arguments unconditionally, for every annotated function with any annotation, while Java made the same decision in two passes and paid only for callables that carry a handler annotation. Measured on 200 annotated NON-handler functions in one file, the Kotlin side-channel payload went from 41069 bytes to 78797 — a doubling, crossing the worker boundary and landing in the durable store, for data no consumer reads today. The reason Kotlin had no prefilter is real and is preserved: an import alias (`EventListener as SpringEvent`) gives a handler annotation a local name no list can contain, so discarding CALLABLES by simple name would lose them before the post-import resolver runs. That argument covers capturing the annotation; it does not cover reading its arguments, because the alias is not a mystery at capture time. The import header states both the local name and the FQN it stands for, so the existing relevance predicate can be asked about the IMPORTED name and the answer carried back to the alias. Kotlin now runs Java's two passes, with that alias set widening the first one. Every annotated function still produces a fact with the same name and use-site target as before — the non-handler payload is 41069 bytes again, byte for byte what it cost before arguments existed — while handlers, and handlers reached only through an alias, keep their arguments. Also corrects two comments that described behavior the code did not have. The Java capture claimed an economy Kotlin was not making; it now describes both languages. The argument opt-in on both DI modules claimed it kept argument text off the wire, but every DI fact already carries the annotation's full source text — what the opt-in avoids is a second, parsed copy, and the comment now says so. The test file is renamed: `spring-handler-annotation-arguments` differed from the pre-existing `spring-annotation-arguments` by one word in the middle, though they cover different mechanisms — an AST capture versus a text parser. It is now `spring-argument-fact-capture`, after the module it exercises. Co-Authored-By: Claude Opus 5 (1M context) * docs(ingestion): correct the argument-text contract the normalizer outgrew Both `SpringNonHttpHandlerAnnotationFact.args` and `SpringArgumentFact.text` promised the value stays "exactly as written". That was true when the field was added and stopped being true in the same branch, when argument text started going through `normalizeSpringFactText` so that one destination written across two lines would not compare unequal to the same reference on one line. A consumer reading only the interface would have assumed a source spelling the fact does not retain — and the indentation such a consumer would have seen is the enclosing block's, not a property of the expression at all. Both docs now state the single rewrite and its reason, and still say plainly that nothing is resolved. Reported by the review bot on #3128; the claim was introduced by this branch, not inherited. Co-Authored-By: Claude Opus 5 (1M context) * style(ingestion): apply Prettier to the four files CI flagged `quality / format` runs `prettier --check .` and four files from this branch had drifted: two line-width wraps and two of the opposite kind, where a call fits on one line. No behaviour change — tsc clean, the four affected suites still pass 126 tests. Worth noting why the pre-commit hook did not catch it: lint-staged formats staged files, but a rebase replays commits without running hooks, so anything that only becomes unformatted relative to a moved base slips through. Checking the whole diff against `prettier --check` before pushing is the reliable step. Co-Authored-By: Claude Opus 5 (1M context) * test(ingestion): publish Kotlin named arguments through a Kotlin template Kotlin forbids named arguments when the callee is a Java method: parameter names are not guaranteed to survive into bytecode, so the compiler refuses `kafkaTemplate.send(topic = ..., data = ...)` for the Spring `KafkaTemplate` imported from `org.springframework.kafka.core`. Every named-argument example in this feature was written that way, which asserted capture on source that could never compile. The path itself is real and stays covered. The classifier matches on the receiver's NAME, so a template declared in Kotlin is recognized exactly like the Spring one, and named arguments to it are legal. Each affected example now declares that template and publishes through it; the assertions are unchanged except for the one receiver spelling they name. The fixture's `publishWithNamedArguments` had no test reading it at all, so it carried the illegal shape into an app fixture for nothing. It is removed, and the two pipeline expectations that counted its publish drop a row. Co-Authored-By: Claude Opus 5 (1M context) * fix(ingestion): withhold a broker when the receiver name matches two templates Widening the receiver-name rule from a suffix to a substring — needed to accept `kafkaTemplateDlq`, `KAFKA_TEMPLATE`, and the rest — also let ONE receiver satisfy TWO signatures. `KafkaTemplate` and `StreamBridge` both publish through `send`, `RabbitTemplate` and `JmsTemplate` both through `convertAndSend`, so `streamBridgeKafkaTemplate.send(...)` matched twice and the loop returned whichever came first in the list: kafka, by declaration order alone. The receiver's TYPE is deliberately never resolved here, so nothing in this module can rank the two matches. Neither the longest match, nor the last one, nor the order of the signature list is evidence about the bean: that name reads equally as a KafkaTemplate fronted by a stream binding or a StreamBridge named after the broker behind it. Publishing one of them as the template turned an unanswered question into a definite attribution a consumer has no way to distinguish from a resolved one — a publish routed to the wrong broker. An ambiguous receiver now yields no fact at all. That costs a rare publish, stays recoverable by a later phase that owns type information, and is the failure this capture already prefers everywhere else. Both outcomes are pinned: three receivers naming two templates yield nothing, while decorated names that merely look long (`orderStreamBridge`, `streamingKafkaTemplate`) still resolve. The `typeName` contract said "suffix", which the same widening had made false. Co-Authored-By: Claude Opus 5 (1M context) * docs(ingestion): correct two argument contracts this change set invalidated Sweeping the Spring capture comments for claims the feature commits outgrew turned up two more, both about what a MISSING argument list means. `SpringNonHttpHandlerAnnotationFact.args` promised that absence means the annotation was written without an argument list. Reading Kotlin arguments on evidence gave absence a second cause: Kotlin still produces a fact for every annotated function — it has no name prefilter, so an import alias cannot hide a handler — but reads arguments only for callables carrying a handler annotation, so a non-handler fact has no arguments however its annotation was written. Java produces facts for handler-bearing callables only, so there the old reading still holds. The field now states both causes and which language has which. `SpringArgumentFact.name` said a template call gives its destination by position. That is true of Java, which has no named arguments, but Kotlin names call arguments whenever the callee is declared in Kotlin, and this module captures the key when it does — the reason the field exists for calls at all. Co-Authored-By: Claude Opus 5 (1M context) * fix(ingestion): make the Kotlin argument reader refuse recovered syntax itself `kotlinValueArgumentFacts` is exported and already has a caller in another module, and its contract said the caller MUST reject a recovered list first. Both callers did. But a guard that every future caller has to remember is the same fragility this change set exists to remove — the Java twin is safe only because it is module-private with one call site. It now returns `null` for a recovered list, so the decision is unavoidable at the type level, and each caller answers in the way its fact requires: a producer call drops the whole fact, having no state for "published somewhere unreadable", while an annotation reports no arguments and collapses into the marker form. Both say "nothing here to resolve", which is true. No behaviour change — the same 126 tests across the three affected suites pass, including the truncated-annotation and truncated-call cases that pin the fail-closed path. Raised as hardening in the maintainer review of #3128. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergő Magyar --- .../frameworks/spring/argument-facts.ts | 140 ++ .../frameworks/spring/message-producers.ts | 140 ++ .../frameworks/spring/non-http-handlers.ts | 29 + .../languages/java/capture-side-channel.ts | 28 + .../core/ingestion/languages/java/captures.ts | 21 +- .../ingestion/languages/java/spring-di.ts | 69 +- .../java/spring-message-producers.ts | 100 ++ .../java/spring-non-http-handlers.ts | 16 +- .../languages/kotlin/capture-side-channel.ts | 29 + .../ingestion/languages/kotlin/captures.ts | 21 +- .../ingestion/languages/kotlin/spring-di.ts | 124 +- .../kotlin/spring-message-producers.ts | 132 ++ .../kotlin/spring-non-http-handlers.ts | 76 +- .../src/core/ingestion/utils/ast-helpers.ts | 19 + gitnexus/src/storage/parse-cache.ts | 15 +- .../com/example/handlers/OrderPublishers.java | 54 + .../com/example/handlers/OrderPublishers.kt | 41 + .../spring-non-http-handlers-pipeline.test.ts | 179 +++ .../test/unit/incremental-parse-cache.test.ts | 6 +- .../unit/spring-argument-fact-capture.test.ts | 742 ++++++++++ .../test/unit/spring-bean-extractor.test.ts | 6 +- .../unit/spring-message-producers.test.ts | 1310 +++++++++++++++++ 22 files changed, 3267 insertions(+), 30 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts create mode 100644 gitnexus/test/fixtures/spring-non-http-handler-app/src/main/java/com/example/handlers/OrderPublishers.java create mode 100644 gitnexus/test/fixtures/spring-non-http-handler-app/src/main/kotlin/com/example/handlers/OrderPublishers.kt create mode 100644 gitnexus/test/unit/spring-argument-fact-capture.test.ts create mode 100644 gitnexus/test/unit/spring-message-producers.test.ts 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([]); + }); +});