feat(ingestion): capture Spring handler annotation arguments and template publishes (#3128)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
glier 2026-09-01 16:35:19 +03:00 • committed by GitHub
parent 52924ef12c
commit f7a58cf188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3267 additions and 30 deletions

View file

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

View file

@ -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<string> = 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[];
}

View file

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

View file

@ -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<string, readonly JavaSpringConditionalFac
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly JavaSpringNonHttpHandlerFact[]>();
const springMessageProducerFacts = new Map<string, readonly SpringMessageProducerFact[]>();
/** 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,

View file

@ -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<number>();
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
const springDynamicLookupNodeIds = new Set<number>();
const springMessageProducerFacts: SpringMessageProducerFact[] = [];
const springMemberCallNodeIds = new Set<number>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@ -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),

View file

@ -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<JavaAnnotationSyntaxFact>;
@ -36,7 +95,10 @@ export type JavaSpringDiClassFact = SpringDiClassFact<
>;
type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact<JavaAnnotationSyntaxFact>;
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 }),
});
}
}

View file

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

View file

@ -11,7 +11,17 @@ import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spri
export type JavaSpringNonHttpHandlerFact = SpringNonHttpHandlerFact<JavaAnnotationSyntaxFact>;
/** 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' }),

View file

@ -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<string, readonly KotlinSpringDiClassFact[]>();
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly KotlinSpringNonHttpHandlerFact[]>();
const springConfigConsumerFacts = new Map<string, readonly KotlinSpringConfigConsumerFact[]>();
const springMessageProducerFacts = new Map<string, readonly SpringMessageProducerFact[]>();
/**
* 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,

View file

@ -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<number>();
const springDiClassNodeIds = new Set<number>();
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
const springDynamicLookupNodeIds = new Set<number>();
const springMessageProducerFacts: SpringMessageProducerFact[] = [];
const springMemberCallNodeIds = new Set<number>();
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, String> kafkaTemplate;
private final RabbitTemplate rabbitTemplate;
private final JmsTemplate jmsTemplate;
private final StreamBridge streamBridge;
@Value("${app.messaging.orders-topic}")
private String ordersTopic;
public OrderPublishers(
KafkaTemplate<String, String> 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff