mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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>
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
collectJavaCaptureSideChannel,
|
|
type JavaCaptureSideChannel,
|
|
} from '../../src/core/ingestion/languages/java/capture-side-channel.js';
|
|
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js';
|
|
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.js';
|
|
import { deriveSpringBeanMetadata } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js';
|
|
import {
|
|
collectKotlinCaptureSideChannel,
|
|
type KotlinCaptureSideChannel,
|
|
} from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
|
|
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
|
|
import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.js';
|
|
|
|
function captureClassAnnotations(code: string): JavaCaptureSideChannel['classAnnotations'] {
|
|
const filePath = 'src/Test.java';
|
|
emitJavaScopeCaptures(code, filePath);
|
|
return collectJavaCaptureSideChannel(filePath)?.classAnnotations ?? [];
|
|
}
|
|
|
|
function captureSpringDiFacts(code: string): NonNullable<JavaCaptureSideChannel['springDiFacts']> {
|
|
const filePath = 'src/Test.java';
|
|
emitJavaScopeCaptures(code, filePath);
|
|
return collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? [];
|
|
}
|
|
|
|
describe('Java class annotation capture', () => {
|
|
it('collects annotation names during the existing scope-query traversal', () => {
|
|
const facts = captureClassAnnotations(`
|
|
@Component("widget") class Widget {
|
|
@Deprecated @Service static class BillingService {}
|
|
}
|
|
@org.springframework.context.annotation.Configuration class AppConfiguration {}
|
|
|
|
@Service interface ServiceContract {}
|
|
@Service enum ServiceState { READY }
|
|
@Service record ServiceRecord(String value) {}
|
|
@Service @interface ServiceMarker {}
|
|
`);
|
|
|
|
expect(facts.map((fact) => fact.annotationNames)).toEqual([
|
|
['Component'],
|
|
['Deprecated', 'Service'],
|
|
['org.springframework.context.annotation.Configuration'],
|
|
]);
|
|
});
|
|
|
|
it('clears worker side-channel facts at the start of each workspace pass', async () => {
|
|
const filePath = 'src/Stale.java';
|
|
emitJavaScopeCaptures('@Service class Stale {}', filePath);
|
|
expect(collectJavaCaptureSideChannel(filePath)?.classAnnotations).toHaveLength(1);
|
|
|
|
await javaScopeResolver.loadResolutionConfig?.('/tmp/repo');
|
|
|
|
expect(collectJavaCaptureSideChannel(filePath)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Java Spring injection syntax capture', () => {
|
|
it('preserves constructor, field, method, qualifier, and bean-name syntax in the side channel', () => {
|
|
const facts = captureSpringDiFacts(`
|
|
@Service("checkout") class Checkout {
|
|
Checkout(@Qualifier("fastGateway") Gateway gateway) {}
|
|
@Autowired Gateway fallback;
|
|
@Inject void setRepo(Repo repo) {}
|
|
}
|
|
`);
|
|
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0].classAnnotations).toEqual([
|
|
{ name: 'Service', text: '@Service("checkout")', line: 2 },
|
|
]);
|
|
expect(facts[0].injectionSites).toMatchObject([
|
|
{
|
|
kind: 'constructor',
|
|
implicitConstructor: true,
|
|
dependencies: [
|
|
{
|
|
name: 'gateway',
|
|
rawType: 'Gateway',
|
|
annotations: [{ name: 'Qualifier', text: '@Qualifier("fastGateway")' }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: 'field',
|
|
memberName: 'fallback',
|
|
dependencies: [{ name: 'fallback', rawType: 'Gateway' }],
|
|
},
|
|
{
|
|
kind: 'method',
|
|
memberName: 'setRepo',
|
|
dependencies: [{ name: 'repo', rawType: 'Repo' }],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel['classAnnotations'] {
|
|
const filePath = 'src/Test.kt';
|
|
emitKotlinScopeCaptures(code, filePath);
|
|
return collectKotlinCaptureSideChannel(filePath)?.classAnnotations ?? [];
|
|
}
|
|
|
|
function captureKotlinSpringDiFacts(
|
|
code: string,
|
|
): NonNullable<KotlinCaptureSideChannel['springDiFacts']> {
|
|
const filePath = 'src/Test.kt';
|
|
emitKotlinScopeCaptures(code, filePath);
|
|
return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
|
|
}
|
|
|
|
function collectKotlinSpringNonHttpHandlerFactsFromSource(
|
|
code: string,
|
|
): NonNullable<KotlinCaptureSideChannel['springNonHttpHandlerFacts']> {
|
|
const filePath = 'src/Test.kt';
|
|
emitKotlinScopeCaptures(code, filePath);
|
|
return collectKotlinCaptureSideChannel(filePath)?.springNonHttpHandlerFacts ?? [];
|
|
}
|
|
|
|
describe('Kotlin class annotation capture', () => {
|
|
it('captures supported class forms and excludes non-candidate declarations', () => {
|
|
const facts = captureKotlinClassAnnotations(`
|
|
@Component class Widget
|
|
@Service("billing") data class BillingService(val name: String)
|
|
@org.springframework.context.annotation.Configuration sealed class AppConfiguration
|
|
@Service value class ServiceId(val value: String)
|
|
class Outer { @Service class NestedService }
|
|
|
|
@Service interface ServiceContract
|
|
@Service object ServiceObject
|
|
@Service enum class ServiceState { READY }
|
|
@Service annotation class ServiceMarker
|
|
`);
|
|
|
|
expect(facts.map((fact) => fact.annotationNames)).toEqual([
|
|
['Component'],
|
|
['Service'],
|
|
['org.springframework.context.annotation.Configuration'],
|
|
['Service'],
|
|
['Service'],
|
|
]);
|
|
});
|
|
|
|
it('clears annotation facts while preserving the Kotlin side-channel lifecycle', async () => {
|
|
const filePath = 'src/Stale.kt';
|
|
emitKotlinScopeCaptures('@Service class Stale', filePath);
|
|
expect(collectKotlinCaptureSideChannel(filePath)?.classAnnotations).toHaveLength(1);
|
|
|
|
await kotlinScopeResolver.loadResolutionConfig?.('/tmp/repo');
|
|
|
|
expect(collectKotlinCaptureSideChannel(filePath)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Kotlin Spring non-HTTP handler syntax capture', () => {
|
|
it('captures class-like owners while persisting only annotation resolution fields', () => {
|
|
const facts = collectKotlinSpringNonHttpHandlerFactsFromSource(`
|
|
class RegularHandlers {
|
|
@Scheduled fun regularHandler() {}
|
|
@receiver:EventListener fun String.targetedHandler() {}
|
|
}
|
|
|
|
object SingletonHandlers {
|
|
@KafkaListener(topics = ["orders"])
|
|
fun singletonHandler() {}
|
|
}
|
|
|
|
class CompanionHolder {
|
|
companion object {
|
|
@TransactionalEventListener fun companionHandler(event: Any) {}
|
|
}
|
|
}
|
|
|
|
enum class EnumHandlers {
|
|
READY;
|
|
@XxlJob("enum-handler") fun enumHandler() {}
|
|
}
|
|
`);
|
|
|
|
const annotations = facts
|
|
.flatMap((fact) => fact.annotations)
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
expect(annotations).toEqual([
|
|
{ name: 'EventListener', useSiteTarget: 'receiver' },
|
|
{ name: 'KafkaListener', args: [{ name: 'topics', text: '["orders"]' }] },
|
|
{ name: 'Scheduled' },
|
|
{ name: 'TransactionalEventListener' },
|
|
{ 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');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Kotlin Spring injection syntax capture', () => {
|
|
it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => {
|
|
const facts = captureKotlinSpringDiFacts(`
|
|
@Service("checkout") @Primary
|
|
class Checkout @Autowired constructor(
|
|
@param:Qualifier("fastGateway") private val gateway: PaymentGateway,
|
|
@Named("repo") repo: Repo?,
|
|
val gateways: List<out PaymentGateway>,
|
|
) {
|
|
@field:Autowired
|
|
@field:Qualifier("slowGateway")
|
|
lateinit var fallback: PaymentGateway
|
|
|
|
@set:Inject
|
|
var optional: Repo? = null
|
|
|
|
@Inject
|
|
fun setRepo(@Named("repo") repo: Repo) {}
|
|
}
|
|
`);
|
|
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0].classAnnotations).toEqual([
|
|
{ name: 'Service', text: '@Service("checkout")', line: 2 },
|
|
{ name: 'Primary', text: '@Primary', line: 2 },
|
|
]);
|
|
expect(facts[0].injectionSites).toMatchObject([
|
|
{
|
|
kind: 'constructor',
|
|
implicitConstructor: false,
|
|
dependencies: [
|
|
{
|
|
name: 'gateway',
|
|
rawType: 'PaymentGateway',
|
|
annotations: [
|
|
{
|
|
name: 'Qualifier',
|
|
text: '@param:Qualifier("fastGateway")',
|
|
useSiteTarget: 'param',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'repo',
|
|
rawType: 'Repo?',
|
|
annotations: [{ name: 'Named', text: '@Named("repo")' }],
|
|
},
|
|
{
|
|
name: 'gateways',
|
|
rawType: 'List<out PaymentGateway>',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: 'property',
|
|
memberName: 'fallback',
|
|
annotations: [
|
|
{ name: 'Autowired', text: '@field:Autowired', useSiteTarget: 'field' },
|
|
{
|
|
name: 'Qualifier',
|
|
text: '@field:Qualifier("slowGateway")',
|
|
useSiteTarget: 'field',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: 'property',
|
|
memberName: 'optional',
|
|
annotations: [{ name: 'Inject', text: '@set:Inject', useSiteTarget: 'set' }],
|
|
},
|
|
{
|
|
kind: 'method',
|
|
memberName: 'setRepo',
|
|
dependencies: [
|
|
{
|
|
name: 'repo',
|
|
rawType: 'Repo',
|
|
annotations: [{ name: 'Named', text: '@Named("repo")' }],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('captures sole stereotype primary constructors as implicit injection sites', () => {
|
|
const facts = captureKotlinSpringDiFacts(`
|
|
@Service
|
|
class Checkout(private val gateway: PaymentGateway)
|
|
`);
|
|
|
|
expect(facts[0].injectionSites).toMatchObject([
|
|
{
|
|
kind: 'constructor',
|
|
implicitConstructor: true,
|
|
dependencies: [{ name: 'gateway', rawType: 'PaymentGateway' }],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('deriveSpringBeanMetadata', () => {
|
|
it('maps all supported canonical stereotypes to roles', () => {
|
|
const cases = [
|
|
['org.springframework.stereotype.Component', 'component'],
|
|
['org.springframework.stereotype.Service', 'service'],
|
|
['org.springframework.stereotype.Repository', 'repository'],
|
|
['org.springframework.stereotype.Controller', 'controller'],
|
|
['org.springframework.web.bind.annotation.RestController', 'rest-controller'],
|
|
['org.springframework.context.annotation.Configuration', 'configuration'],
|
|
] as const;
|
|
|
|
for (const [annotation, role] of cases) {
|
|
expect(deriveSpringBeanMetadata([annotation])).toEqual({
|
|
framework: 'spring',
|
|
role,
|
|
annotation,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('omits conflicting or unsupported evidence', () => {
|
|
expect(
|
|
deriveSpringBeanMetadata([
|
|
'org.springframework.stereotype.Service',
|
|
'org.springframework.stereotype.Component',
|
|
]),
|
|
).toBeUndefined();
|
|
expect(deriveSpringBeanMetadata(['com.example.Service'])).toBeUndefined();
|
|
});
|
|
});
|