import { describe, it, expect } from 'vitest'; import { hasUnescapedStringInterpolation, isAddressShaped, parseSpringStringLiteral, resolveSpringDestination, resolveSpringPlaceholders, selectConsumerDestinationArguments, selectProducerDestinationArguments, splitSpringDestinationList, type SpringDestinationCandidate, } from '../../src/core/ingestion/frameworks/spring/destinations.js'; /** * The pure half of Spring destination resolution: argument selection, the * four-step cascade, and the refusal taxonomy. * * The NEGATIVE cases are pinned as hard as the positive ones on purpose. The * recurring failure mode in this area is a suppression that also eats correct * results, and the single most important assertion in the whole feature is that * an UNRESOLVED address never yields something two services could join on — so * every path that declines is asserted to decline for a NAMED reason, not * merely to produce nothing. */ const candidate = ( rawText: string, overrides: Partial = {}, ): SpringDestinationCandidate => ({ role: 'consumer', source: 'KafkaListener', broker: 'kafka', argIndex: 0, elementIndex: 0, rawText, ...overrides, }); describe('splitSpringDestinationList', () => { it('splits the Java brace form', () => { expect(splitSpringDestinationList('{"orders", "shipments"}')).toEqual([ '"orders"', '"shipments"', ]); }); it('splits the Kotlin bracket form', () => { expect(splitSpringDestinationList('["orders", "shipments"]')).toEqual([ '"orders"', '"shipments"', ]); }); it('splits the Kotlin arrayOf form', () => { expect(splitSpringDestinationList('arrayOf("orders", "shipments")')).toEqual([ '"orders"', '"shipments"', ]); }); it('leaves a scalar argument as one element', () => { expect(splitSpringDestinationList('"orders"')).toEqual(['"orders"']); expect(splitSpringDestinationList('Topics.ORDERS')).toEqual(['Topics.ORDERS']); }); it('does not split on a comma inside a string literal', () => { expect(splitSpringDestinationList('{"a,b", "c"}')).toEqual(['"a,b"', '"c"']); }); it('does not split on a comma inside a nested call', () => { expect(splitSpringDestinationList('{join("a", "b"), "c"}')).toEqual(['join("a", "b")', '"c"']); }); it('reports an empty list as empty, which is different from one element', () => { expect(splitSpringDestinationList('{}')).toEqual([]); expect(splitSpringDestinationList('[]')).toEqual([]); expect(splitSpringDestinationList('arrayOf()')).toEqual([]); }); }); describe('parseSpringStringLiteral', () => { it('unquotes the ordinary form', () => { expect(parseSpringStringLiteral('"orders.v1"')).toBe('orders.v1'); }); it('unquotes a Kotlin raw string', () => { expect(parseSpringStringLiteral('"""orders.v1"""')).toBe('orders.v1'); }); it('undoes the Kotlin dollar escape so a placeholder is still recognizable', () => { // Kotlin requires `\$` or the compiler reads `${...}` as a string template. // Without this the placeholder would be misfiled as a literal address. expect(parseSpringStringLiteral('"\\${app.orders.topic}"')).toBe('${app.orders.topic}'); }); it('returns null for anything that is not a single literal', () => { expect(parseSpringStringLiteral('Topics.ORDERS')).toBeNull(); expect(parseSpringStringLiteral('"a" + "b"')).toBeNull(); expect(parseSpringStringLiteral('{"a"}')).toBeNull(); }); it('does not fold a concatenation of two raw strings into one literal', () => { // A greedy triple-quote body swallowed the operator and produced the single // address `a""" + """b`, which no source ever wrote. expect(parseSpringStringLiteral('"""a""" + """b"""')).toBeNull(); expect(parseSpringStringLiteral('"""a"""+"""b"""')).toBeNull(); }); }); describe('hasUnescapedStringInterpolation', () => { it('sees the two Kotlin template forms', () => { expect(hasUnescapedStringInterpolation('"orders-$env"')).toBe(true); expect(hasUnescapedStringInterpolation('"orders-${env}"')).toBe(true); }); it('does not see an ESCAPED dollar, which is how a Spring placeholder is written', () => { expect(hasUnescapedStringInterpolation('"\\${app.topic}"')).toBe(false); }); it('treats every dollar in a raw string as an interpolation', () => { // Kotlin raw strings have no backslash escapes at all. expect(hasUnescapedStringInterpolation('"""orders-$env"""')).toBe(true); expect(hasUnescapedStringInterpolation('"""orders"""')).toBe(false); }); it('ignores a dollar that starts no template', () => { expect(hasUnescapedStringInterpolation('"price-$"')).toBe(false); expect(hasUnescapedStringInterpolation('"cost-$9"')).toBe(false); }); }); describe('resolveSpringPlaceholders', () => { it('passes a value with no placeholder through as plain', () => { expect(resolveSpringPlaceholders('orders')).toEqual({ plain: true }); }); it('reports a default without substituting it', () => { // The default is written in the source, so it is READ — but configuration // can override it and this graph never sees configuration values, so it is // provenance, not an address and not an identity. expect(resolveSpringPlaceholders('${app.orders.topic:orders}')).toEqual({ plain: false, key: 'app.orders.topic', defaultValue: 'orders', }); }); it('splits on the first colon only', () => { expect(resolveSpringPlaceholders('${app.topic:a:b}')).toEqual({ plain: false, key: 'app.topic', defaultValue: 'a:b', }); }); it('reads the first placeholder of a larger value', () => { expect(resolveSpringPlaceholders('prefix-${env:dev}-orders')).toEqual({ plain: false, key: 'env', defaultValue: 'dev', }); }); it('reports the key, and no default, when there is none', () => { expect(resolveSpringPlaceholders('${app.orders.topic}')).toEqual({ plain: false, key: 'app.orders.topic', }); }); it('reports a nested default as written, without expanding it', () => { expect(resolveSpringPlaceholders('${a:${b}}')).toEqual({ plain: false, key: 'a', defaultValue: '${b}', }); }); it('reports an empty key for a placeholder that names none', () => { expect(resolveSpringPlaceholders('${}')).toEqual({ plain: false, key: '' }); }); it('refuses an unterminated placeholder rather than treating it as text', () => { expect(resolveSpringPlaceholders('${app.topic').key).toBe('app.topic'); }); }); describe('the resolution cascade', () => { it('step 1 resolves a literal', () => { expect(resolveSpringDestination(candidate('"orders"'))).toEqual({ kind: 'resolved', address: 'orders', via: 'literal', }); }); it('step 2 resolves a constant through the supplied resolver', () => { const resolution = resolveSpringDestination(candidate('Topics.ORDERS'), { constant: (name) => (name === 'Topics.ORDERS' ? 'orders.v1' : null), }); expect(resolution).toEqual({ kind: 'resolved', address: 'orders.v1', via: 'constant' }); }); it('step 2 refuses, by name, when the constant cannot be folded', () => { expect(resolveSpringDestination(candidate('Topics.ORDERS'), { constant: () => null })).toEqual({ kind: 'unresolved', reason: 'unresolved-constant', }); }); it('step 3 does NOT resolve a placeholder default, and keeps both key and default', () => { // A default holds only while the key is not overridden, and configuration // VALUES are deliberately absent from this graph, so the code cannot know // whether it holds. Keying on it merged every service that copy-pasted the // same fallback. expect(resolveSpringDestination(candidate('"${app.orders.topic:orders}"'))).toEqual({ kind: 'unresolved', reason: 'overridable-config-default', configKey: 'app.orders.topic', configDefault: 'orders', }); }); it('does not merge two DIFFERENT keys that share a default', () => { // The reproduction: `${a.topic:events}` in one service and // `${b.topic:events}` in another collapsed onto one `Destination:events` // and reported a producer/consumer pair between two unrelated services. const a = resolveSpringDestination(candidate('"${a.topic:events}"')); const b = resolveSpringDestination(candidate('"${b.topic:events}"')); expect(a).not.toHaveProperty('address'); expect(b).not.toHaveProperty('address'); expect(a).toMatchObject({ configKey: 'a.topic', configDefault: 'events' }); expect(b).toMatchObject({ configKey: 'b.topic', configDefault: 'events' }); }); it('step 3 does NOT resolve a placeholder without a default, and records the key', () => { expect(resolveSpringDestination(candidate('"${app.orders.topic}"'))).toEqual({ kind: 'unresolved', reason: 'unresolved-config-key', configKey: 'app.orders.topic', }); }); it('runs step 3 on the value a constant folded to', () => { // `static final String TOPIC = "${app.orders.topic}"` is an ordinary way to // write a placeholder. Stopping after step 2 would publish the placeholder // text as a resolved address — a shared identity for unrelated services. const resolution = resolveSpringDestination(candidate('Topics.ORDERS'), { constant: () => '${app.orders.topic}', }); expect(resolution).toEqual({ kind: 'unresolved', reason: 'unresolved-config-key', configKey: 'app.orders.topic', }); }); it('never returns the placeholder text as an address', () => { const resolution = resolveSpringDestination(candidate('"${app.topic}"')); expect(resolution.kind).toBe('unresolved'); expect(resolution).not.toHaveProperty('address'); }); it('refuses an expression that is neither a literal nor a constant reference', () => { expect(resolveSpringDestination(candidate('"a" + suffix()'))).toEqual({ kind: 'unresolved', reason: 'not-a-literal-or-constant', }); }); it('refuses an empty literal, and says which kind of empty it was', () => { // An empty address addresses nothing, and letting it through would give // every such site one shared `''` identity. expect(resolveSpringDestination(candidate('""'))).toEqual({ kind: 'unresolved', reason: 'empty-literal-address', }); expect(resolveSpringDestination(candidate('" "'))).toEqual({ kind: 'unresolved', reason: 'empty-literal-address', }); expect(resolveSpringDestination(candidate('Topics.EMPTY'), { constant: () => '' })).toEqual({ kind: 'unresolved', reason: 'empty-constant-address', }); }); it('files `${key:}` as an overridable default, keeping the key it used to drop', () => { // It used to land in `empty-address`, which threw the key away and merged // the case with three unrelated causes. expect(resolveSpringDestination(candidate('"${app.topic:}"'))).toEqual({ kind: 'unresolved', reason: 'overridable-config-default', configKey: 'app.topic', configDefault: '', }); }); it('refuses `${}` without ever reporting an empty configuration key', () => { const resolution = resolveSpringDestination(candidate('"${}"')); expect(resolution).toEqual({ kind: 'unresolved', reason: 'empty-config-key' }); expect(resolution).not.toHaveProperty('configKey'); }); it('keeps an address exactly as written, whitespace included', () => { // The emptiness test trims; the address does not. `" orders "` gets its own // node rather than joining `orders` — a missing connection, which is the // direction this module errs in everywhere. expect(resolveSpringDestination(candidate('" orders "'))).toEqual({ kind: 'resolved', address: ' orders ', via: 'literal', }); }); // ── SpEL: a runtime bean expression is not an address ──────────────────── it('refuses a SpEL expression instead of filing it as a literal address', () => { // Reproduced before the fix: two unrelated services that each wrote // `@KafkaListener(topics = "#{@kafkaProps.ordersTopic}")` produced ONE node // with `address = "#{@kafkaProps.ordersTopic}"` and a CONSUMES_FROM edge // from each. for (const spel of [ '"#{@kafkaProps.ordersTopic}"', '"#{environment[\'app.topic\']}"', '"#{T(Topics).ORDERS}"', '"orders-#{@env.suffix}"', ]) { const resolution = resolveSpringDestination(candidate(spel)); expect(resolution).toEqual({ kind: 'unresolved', reason: 'spel-expression' }); expect(resolution).not.toHaveProperty('address'); } }); it('calls a SpEL expression that wraps a placeholder SpEL, not a config key', () => { // `"#{'${app.topics}'.split(',')}"` was caught only by accident, because it // happens to contain `${`. The diagnosis has to name what it really is. expect(resolveSpringDestination(candidate("\"#{'${app.topics}'.split(',')}\""))).toEqual({ kind: 'unresolved', reason: 'spel-expression', }); }); // ── String templates in a language that interpolates ───────────────────── it('refuses an unescaped interpolation where literals interpolate', () => { for (const template of ['"orders-$env"', '"orders-${env}"', '"""orders-$env"""']) { const resolution = resolveSpringDestination(candidate(template), { interpolatesStringLiterals: true, }); expect(resolution).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' }); expect(resolution).not.toHaveProperty('address'); expect(resolution).not.toHaveProperty('configKey'); } }); it('still reads the ESCAPED form as a Spring placeholder where literals interpolate', () => { expect( resolveSpringDestination(candidate('"\\${app.topic}"'), { interpolatesStringLiterals: true, }), ).toEqual({ kind: 'unresolved', reason: 'unresolved-config-key', configKey: 'app.topic' }); }); it('leaves a non-interpolating language exactly as it was', () => { // In Java `$` is an ordinary character and `${...}` is a Spring placeholder. expect(resolveSpringDestination(candidate('"orders-$env"'))).toEqual({ kind: 'resolved', address: 'orders-$env', via: 'literal', }); expect(resolveSpringDestination(candidate('"${app.topic}"'))).toEqual({ kind: 'unresolved', reason: 'unresolved-config-key', configKey: 'app.topic', }); }); it('does not invent a configKey from a Kotlin `${var}` template', () => { // The old reading produced `configKey: "env"` and the phase then emitted a // USES edge to any Property named `env` — provenance with no source. const resolution = resolveSpringDestination(candidate('"orders-${env}"'), { interpolatesStringLiterals: true, }); expect(resolution).not.toHaveProperty('configKey'); }); it('refuses an interpolating constant rather than folding it to an address', () => { // A LIVE path, not a guard for a hypothetical future provider: Kotlin both // interpolates and supplies a constant fold (`languages/kotlin.ts` declares // `extractModuleConstants` and `foldRoutePathOperands`), and the pipeline // test `folds a KOTLIN constant, not only a Java one` shows the fold // reaching this cascade end to end. expect( resolveSpringDestination(candidate('Topics.TEMPLATE'), { constant: () => 'orders-$env', interpolatesStringLiterals: true, }), ).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' }); }); it('misfiles a folded placeholder as interpolation — the cost of the lost escape', () => { // By the time a folded value reaches this branch its escapes are gone, so // `"\${app.topic}"` (a Spring placeholder, in Kotlin) and `"${app.topic}"` // (a runtime template) are the same string and the guard fires first. The // reason is then `unescaped-interpolation` where `unresolved-config-key` // would have been more precise. // // Pinned rather than left implicit because the trade is the point: BOTH // readings are unresolved, so the error can only ever be a misfiled reason // in the refusal breakdown, never a false address. The precise diagnosis // needs the fold to report whether the declaration was escaped, which the // shared constant shape does not carry. const resolution = resolveSpringDestination(candidate('Topics.CONFIGURED'), { constant: () => '${app.topic}', interpolatesStringLiterals: true, }); expect(resolution).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' }); expect(resolution).not.toHaveProperty('address'); }); it('leaves step 4 unimplemented, and consults it only when supplied', () => { // The seam is deliberately not wired in production; this pins its contract. expect(resolveSpringDestination(candidate('"${app.topic}"')).kind).toBe('unresolved'); expect( resolveSpringDestination(candidate('"${app.topic}"'), { specification: () => 'orders.from.spec', }), ).toEqual({ kind: 'resolved', address: 'orders.from.spec', via: 'specification' }); }); }); describe('consumer argument selection', () => { it('reads @KafkaListener(topics = …)', () => { const selection = selectConsumerDestinationArguments( 'org.springframework.kafka.annotation.KafkaListener', [{ name: 'topics', text: '"orders"' }], ); expect(selection?.candidates).toHaveLength(1); expect(selection?.candidates[0]).toMatchObject({ broker: 'kafka', argName: 'topics' }); expect(selection?.refusals).toEqual([]); }); it('reads @RabbitListener(queues = …) and @JmsListener(destination = …)', () => { expect( selectConsumerDestinationArguments('RabbitListener', [{ name: 'queues', text: '"orders"' }]) ?.candidates[0], ).toMatchObject({ broker: 'rabbit' }); expect( selectConsumerDestinationArguments('JmsListener', [{ name: 'destination', text: '"orders"' }]) ?.candidates[0], ).toMatchObject({ broker: 'jms' }); }); it('reads @ServiceActivator(inputChannel = …)', () => { expect( selectConsumerDestinationArguments('ServiceActivator', [ { name: 'inputChannel', text: '"orders"' }, ])?.candidates[0], ).toMatchObject({ broker: 'integration', argName: 'inputChannel' }); }); it('reads a positional argument only where `value` really is the destination', () => { expect( selectConsumerDestinationArguments('SqsListener', [{ text: '"orders"' }])?.candidates, ).toHaveLength(1); expect( selectConsumerDestinationArguments('StreamListener', [{ text: '"orders"' }])?.candidates, ).toHaveLength(1); // @KafkaListener declares no `value` alias for its topics, so a positional // argument there is a different element and must not be guessed at. const kafka = selectConsumerDestinationArguments('KafkaListener', [{ text: '"orders"' }]); expect(kafka?.candidates).toEqual([]); expect(kafka?.refusals.map((r) => r.reason)).toEqual(['no-destination-argument']); }); it('yields one candidate per element for an array, not a group', () => { const selection = selectConsumerDestinationArguments('KafkaListener', [ { name: 'topics', text: '{"orders", "shipments"}' }, ]); expect(selection?.candidates.map((c) => c.rawText)).toEqual(['"orders"', '"shipments"']); expect(selection?.candidates.map((c) => c.elementIndex)).toEqual([0, 1]); }); it('refuses topicPattern as a pattern, not an address', () => { const selection = selectConsumerDestinationArguments('KafkaListener', [ { name: 'topicPattern', text: '"orders.*"' }, ]); expect(selection?.candidates).toEqual([]); expect(selection?.refusals.map((r) => r.reason)).toEqual(['topic-pattern']); }); it('refuses argument shapes it will not read, by name', () => { expect( selectConsumerDestinationArguments('RabbitListener', [ { name: 'bindings', text: '@QueueBinding(value = @Queue("orders"))' }, ])?.refusals.map((r) => r.reason), ).toEqual(['unsupported-annotation-argument']); expect( selectConsumerDestinationArguments('KafkaListener', [ { name: 'topicPartitions', text: '@TopicPartition(topic = "orders", partitions = "0")' }, ])?.refusals.map((r) => r.reason), ).toEqual(['unsupported-annotation-argument']); }); it('refuses an empty destination list rather than passing silently', () => { const selection = selectConsumerDestinationArguments('KafkaListener', [ { name: 'topics', text: '{}' }, ]); expect(selection?.candidates).toEqual([]); expect(selection?.refusals.map((r) => r.reason)).toEqual(['empty-destination-list']); }); it('keeps unread arguments and an empty argument list apart', () => { // Absent = the capture never read the list. Empty = a list WAS read and it // was empty, which is a fact about the source. Filing a source-level gap // under a tooling gap corrupts the reason breakdown this feature is // measured on, and the producer side of this module already distinguishes // them. expect( selectConsumerDestinationArguments('KafkaListener', undefined)?.refusals.map((r) => r.reason), ).toEqual(['annotation-arguments-unavailable']); expect( selectConsumerDestinationArguments('KafkaListener', [])?.refusals.map((r) => r.reason), ).toEqual(['annotation-arguments-empty']); }); it('records a listener whose arguments name no destination', () => { const selection = selectConsumerDestinationArguments('KafkaListener', [ { name: 'groupId', text: '"orders-group"' }, ]); expect(selection?.refusals.map((r) => r.reason)).toEqual(['no-destination-argument']); }); it('excludes WebSocket/STOMP mappings entirely', () => { // @MessageMapping and @SubscribeMapping are session-scoped application // routes, not broker addresses. Modelling them as destinations would put a // STOMP path in the same namespace as a Kafka topic. expect( selectConsumerDestinationArguments('MessageMapping', [{ text: '"/topic/prices"' }]), ).toBeNull(); expect( selectConsumerDestinationArguments('SubscribeMapping', [{ text: '"/topic/prices"' }]), ).toBeNull(); }); it('records a repeated-listener container as a refusal, not as nothing', () => { // Capture does not descend into the nested annotations, so a repository // using these really does lose those destinations. Returning an empty // selection made that indistinguishable from a repository with no // listeners, and was observationally identical to returning `null` — the // caller skips both — so the stated reason for the branch did not hold. for (const [container, broker] of [ ['KafkaListeners', 'kafka'], ['RabbitListeners', 'rabbit'], ['JmsListeners', 'jms'], ['PulsarListeners', 'pulsar'], ] as const) { const selection = selectConsumerDestinationArguments(container, [ { text: '{@Listener(topics = "a")}' }, ]); expect(selection?.candidates).toEqual([]); expect(selection?.refusals).toHaveLength(1); expect(selection?.refusals[0]).toMatchObject({ reason: 'repeated-listener-container', broker, source: container, }); } }); }); describe('producer argument selection', () => { const args = (...texts: string[]) => texts.map((text) => ({ text })); it('takes argument 0 for kafka send', () => { const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', args: args('"orders"', 'payload'), }); expect(selection.candidates).toHaveLength(1); expect(selection.candidates[0]).toMatchObject({ broker: 'kafka', argIndex: 0 }); }); it('takes argument 0 for a stream-bridge binding and reports the broker as stream', () => { expect( selectProducerDestinationArguments({ template: 'stream-bridge', methodName: 'send', args: args('"orders-out-0"', 'payload'), }).candidates[0], ).toMatchObject({ broker: 'stream', argIndex: 0 }); }); it('takes argument 0 for jms convertAndSend', () => { expect( selectProducerDestinationArguments({ template: 'jms', methodName: 'convertAndSend', args: args('"orders"', 'payload'), }).candidates[0], ).toMatchObject({ broker: 'jms', argIndex: 0 }); }); it('refuses a single-argument send: the destination is inside an object', () => { // send(ProducerRecord) / send(Message) / convertAndSend(Object). for (const template of ['kafka', 'jms', 'stream-bridge'] as const) { const selection = selectProducerDestinationArguments({ template, methodName: template === 'kafka' || template === 'stream-bridge' ? 'send' : 'convertAndSend', args: args('record'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual(['producer-arity-unrecognized']); } }); it('refuses a two-argument jms convertAndSend whose slot 0 is not confidently an address', () => { // `convertAndSend(message, postProcessor)` has the same arity as // `convertAndSend(destination, message)`. Shape, not arity, is what tells // them apart, so a payload in slot 0 must refuse rather than be published. const selection = selectProducerDestinationArguments({ template: 'jms', methodName: 'convertAndSend', args: args('message', 'postProcessor'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual([ 'producer-argument-not-address-shaped', ]); }); it('relaxes the jms gate at three arguments, where slot 0 is always the destination', () => { const selection = selectProducerDestinationArguments({ template: 'jms', methodName: 'convertAndSend', args: args('destination', 'message', 'postProcessor'), }); expect(selection.candidates).toHaveLength(1); expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: 'destination' }); }); it('records a Kotlin trailing-lambda call as arguments-unavailable', () => { const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', }); expect(selection.refusals.map((r) => r.reason)).toEqual(['producer-arguments-unavailable']); }); describe('rabbit convertAndSend arity rules', () => { it('refuses one argument: the default exchange has no address in the source', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('payload'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual(['rabbit-default-exchange']); }); it('reads two arguments as routingKey + message', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.created"', 'payload'), }); expect(selection.candidates).toHaveLength(1); expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders.created"' }); expect(selection.candidates[0]?.exchange).toBeUndefined(); }); it('reads three arguments as exchange + routingKey + message', () => { // Slot 1 is a CONSTANT, which is why this one is readable at all: a // literal there would be indistinguishable from a String payload under // the competing `(routingKey, message, correlationData)` overload, and // is refused instead (see the ambiguity test below). const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.exchange"', 'Topics.ORDERS_KEY', 'payload'), }); expect(selection.candidates).toHaveLength(1); // The routing key is the address; the exchange rides along as provenance. expect(selection.candidates[0]).toMatchObject({ argIndex: 1, rawText: 'Topics.ORDERS_KEY', exchange: 'orders.exchange', }); }); it('REFUSES at three arguments when slot 1 is not confidently an address', () => { // `convertAndSend(routingKey, message, postProcessor)` and // `convertAndSend(routingKey, message, correlationData)` are three // arguments with no exchange, and they are indistinguishable from // `convertAndSend(exchange, routingKey, message)` by arity. The former // code fell back to slot 0 here, which is what published an EXCHANGE as // an address; there is no fallback now. const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.created"', 'payload', 'postProcessor'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual([ 'producer-argument-not-address-shaped', ]); // The refusal names the slot that failed, not an arbitrary one. expect(selection.refusals[0]).toMatchObject({ argIndex: 1 }); }); it('never publishes the EXCHANGE as an address when the routing key is a variable', () => { // The reproduction, in the three spellings it was reproduced in. Each of // these is an ordinary Spring AMQP call whose routing key happens to be a // variable; the last two even fold to a real exchange name, so the graph // got a plausible-looking wrong address that can join a // @RabbitListener(queues = "orders"). for (const exchange of ['"orders.exchange"', 'Exchanges.ORDERS', 'ORDERS_EXCHANGE']) { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args(exchange, 'routingKey', 'event'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual([ 'producer-argument-not-address-shaped', ]); } }); it('accepts a screaming-snake constant as confident evidence at three arguments', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('ORDERS_EXCHANGE', 'ORDERS_ROUTING_KEY', 'payload'), }); expect(selection.candidates[0]).toMatchObject({ argIndex: 1, rawText: 'ORDERS_ROUTING_KEY', exchange: 'ORDERS_EXCHANGE', }); }); it('gates four arguments exactly like three', () => { // Arity does not fix the overload here either: // `(routingKey, message, postProcessor, correlationData)` also reaches // four, so the permissive gate that used to apply at this arity let a // payload through as an address. const permissiveOnly = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.exchange"', 'routingKey', 'payload', 'postProcessor'), }); expect(permissiveOnly.candidates).toEqual([]); expect(permissiveOnly.refusals.map((r) => r.reason)).toEqual([ 'producer-argument-not-address-shaped', ]); const confident = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.exchange"', 'ORDERS_ROUTING_KEY', 'payload', 'postProcessor'), }); expect(confident.candidates[0]).toMatchObject({ argIndex: 1, rawText: 'ORDERS_ROUTING_KEY', exchange: 'orders.exchange', }); // ...and the ambiguity rule reaches four arguments too, because // `(routingKey, message, postProcessor, correlationData)` also has four. const ambiguous = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.exchange"', '"body"', 'payload', 'postProcessor'), }); expect(ambiguous.candidates).toEqual([]); expect(ambiguous.refusals.map((r) => r.reason)).toEqual(['ambiguous-producer-overload']); }); it('applies the same rule to the five-argument overload', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"x"', '"orders.created"', 'payload', 'postProcessor', 'correlation'), }); expect(selection.candidates[0]).toMatchObject({ argIndex: 1, exchange: 'x' }); }); it('reads two arguments as routingKey + message only on confident evidence', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('message', 'postProcessor'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual([ 'producer-argument-not-address-shaped', ]); }); it('REFUSES the one ambiguity it cannot settle, rather than choosing', () => { // `convertAndSend("orders.rk", "body", correlationData)` fits two real // overloads that disagree about which slot is the address: // // (exchange, routingKey, message) → the address is `"body"` // (routingKey, message, correlationData) → the address is `"orders.rk"` // // Both are spelled (String, String, ref). This used to take the exchange // reading, which made the String PAYLOAD the address — and a // `@RabbitListener(queues = "body")` anywhere in the repository then // joined a publisher that has never written to it. A refusal is counted // and recoverable; that edge enters a report as a fact. const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.rk"', '"body"', 'correlationData'), }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual(['ambiguous-producer-overload']); // The refusal names the slot that could not be read, not an arbitrary one. expect(selection.refusals[0]).toMatchObject({ argIndex: 1, rawText: '"body"' }); }); // ── The refusal must not eat the cases that ARE decidable ────────────── // // The recurring way a suppression goes wrong here is by taking correct // results down with the wrong one, and the loss is silent: a destination // that stops being emitted looks exactly like a repository that never had // one. Each case below is a reading the syntax really does settle. it('still resolves a CONSTANT routing key at the ambiguous arities', () => { // The spelling is the evidence — the same premise `isConfidentAddressShape` // is built on. `ORDERS_ROUTING_KEY` and `Topics.ORDERS_KEY` are how a // configured NAME is written; a payload computed at the call site is not. for (const key of ['ORDERS_ROUTING_KEY', 'Topics.ORDERS_KEY']) { for (const trailing of [['payload'], ['payload', 'postProcessor']]) { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.exchange"', key, ...trailing), }); expect(selection.refusals).toEqual([]); expect(selection.candidates[0]).toMatchObject({ argIndex: 1, rawText: key, exchange: 'orders.exchange', }); } } }); it('still resolves a string-literal routing key at FIVE arguments', () => { // `(exchange, routingKey, message, postProcessor, correlationData)` is // the only five-argument overload there is, so nothing competes for slot // 1 and the literal is not ambiguous. A refusal written on the shape of // the argument alone, without the arity, would have cut this. const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"x"', '"orders.created"', 'payload', 'postProcessor', 'correlation'), }); expect(selection.refusals).toEqual([]); expect(selection.candidates[0]).toMatchObject({ argIndex: 1, rawText: '"orders.created"', exchange: 'x', }); }); it('still resolves a string-literal routing key at TWO arguments', () => { // `(routingKey, message)` competes only with `(message, postProcessor)` // and `(message, correlationData)`, and slot 0 is the routing key in the // only one of the three that carries an address at all. const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: args('"orders.created"', 'payload'), }); expect(selection.refusals).toEqual([]); expect(selection.candidates[0]).toMatchObject({ argIndex: 0 }); }); it('still resolves a string-literal routing key passed BY NAME', () => { // A name settles which slot is which outright, so the ambiguity the // refusal exists for does not arise. Refusing on the literal alone would // have thrown away the one form that states the answer explicitly. const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: [ { text: '"orders.exchange"' }, { name: 'routingKey', text: '"orders.created"' }, { text: 'payload' }, ], }); expect(selection.refusals).toEqual([]); expect(selection.candidates[0]).toMatchObject({ argName: 'routingKey', rawText: '"orders.created"', }); }); it('does not apply the ambiguity rule to the other templates', () => { // Only Rabbit has an overload family in which slot 1 can be either the // routing key or the message. Kafka, JMS and StreamBridge put the // destination first in every multi-argument overload they have, so a // literal in slot 1 there is simply the payload and says nothing. for (const template of ['kafka', 'jms', 'stream-bridge'] as const) { const selection = selectProducerDestinationArguments({ template, methodName: template === 'kafka' || template === 'stream-bridge' ? 'send' : 'convertAndSend', args: args('"orders.v1"', '"body"', 'extra'), }); expect(selection.refusals).toEqual([]); expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders.v1"' }); } }); }); describe('named arguments', () => { it('selects by NAME, not by position', () => { // Kotlin argument lists need not be in parameter order. // `kafkaTemplate.send(data = "payload", topic = "orders")` selected // args[0] and published the PAYLOAD as the address. const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', args: [ { name: 'data', text: '"payload"' }, { name: 'topic', text: '"orders"' }, ], }); expect(selection.candidates).toHaveLength(1); expect(selection.candidates[0]).toMatchObject({ argIndex: 1, argName: 'topic', rawText: '"orders"', }); }); it('reads a rabbit exchange passed by name as provenance, not as the address', () => { const selection = selectProducerDestinationArguments({ template: 'rabbit', methodName: 'convertAndSend', args: [ { name: 'message', text: 'payload' }, { name: 'exchange', text: '"orders.exchange"' }, { name: 'routingKey', text: '"orders.created"' }, ], }); expect(selection.candidates[0]).toMatchObject({ rawText: '"orders.created"', exchange: 'orders.exchange', }); }); it('knows the destination parameter of the other templates', () => { expect( selectProducerDestinationArguments({ template: 'jms', methodName: 'convertAndSend', args: [ { name: 'message', text: 'payload' }, { name: 'destinationName', text: '"orders.jms"' }, ], }).candidates[0], ).toMatchObject({ rawText: '"orders.jms"' }); expect( selectProducerDestinationArguments({ template: 'stream-bridge', methodName: 'send', args: [ { name: 'data', text: 'payload' }, { name: 'bindingName', text: '"orders-out-0"' }, ], }).candidates[0], ).toMatchObject({ rawText: '"orders-out-0"' }); }); it('still reads a partially named list that puts the destination first', () => { // `send("orders", data = payload)` is legal — positional arguments // precede named ones — and slot 0 really is the topic. Refusing every // list that contains any name at all would have cost this. const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', args: [{ text: '"orders"' }, { name: 'data', text: 'payload' }], }); expect(selection.candidates).toHaveLength(1); expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders"' }); }); it('refuses when the POSITIONAL slot is named after something else', () => { // The residual of the rule above: no name matched a destination // parameter, so the positional reading ran — and landed on an argument // whose own name contradicts it. const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', args: [ { name: 'data', text: '"payload"' }, { name: 'partition', text: '0' }, ], }); expect(selection.candidates).toEqual([]); expect(selection.refusals.map((r) => r.reason)).toEqual([ 'producer-named-argument-unrecognized', ]); }); }); it('keeps the permissive gate where arity already fixes the slot', () => { // kafka `send` puts the topic first in every multi-argument overload, so a // bare identifier is passed to the cascade rather than refused on shape — // and the cascade then refuses it by the name of what it actually tried. const selection = selectProducerDestinationArguments({ template: 'kafka', methodName: 'send', args: args('topic', 'payload'), }); expect(selection.candidates).toHaveLength(1); const [only] = selection.candidates; expect( resolveSpringDestination(only as SpringDestinationCandidate, { constant: () => null }), ).toEqual({ kind: 'unresolved', reason: 'unresolved-constant' }); }); }); describe('isAddressShaped', () => { it('accepts literals and constant references', () => { expect(isAddressShaped('"orders"')).toBe(true); expect(isAddressShaped('ORDERS')).toBe(true); expect(isAddressShaped('Topics.ORDERS')).toBe(true); expect(isAddressShaped('com.example.Topics.ORDERS')).toBe(true); }); it('rejects payload-shaped expressions', () => { expect(isAddressShaped('new ProducerRecord<>("a", b)')).toBe(false); expect(isAddressShaped('buildMessage()')).toBe(false); expect(isAddressShaped('"a" + b')).toBe(false); }); it('accepts a bare identifier, which a resolver may still refuse', () => { // The shape gate says "this could be an address", never "this resolves". expect(isAddressShaped('topic')).toBe(true); expect(resolveSpringDestination(candidate('topic'), { constant: () => null }).kind).toBe( 'unresolved', ); }); });