adl-toolkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. package/scripts/install_toolkit.py +3584 -0
@@ -0,0 +1,540 @@
1
+ """Abstract dispatch fan-out and Spring dependency-injection narrowing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Dict, List, Optional, Set, Tuple, Union
7
+
8
+ from adl_drift.graph.node import NodeType
9
+ from adl_drift.lang.java.ids import DEFAULT_PACKAGE
10
+ from adl_drift.lang.java.resolution.constants import (
11
+ JAVA_COMPONENT_ANNOTATIONS,
12
+ JAVA_DI_ANNOTATION_FQNS,
13
+ JAVA_INJECTION_ANNOTATIONS,
14
+ _ANNOTATION_STRING_ARG,
15
+ _CONCRETE_TYPE_KINDS,
16
+ _CONF_DI_SINGLE_COMPONENT,
17
+ _CONF_MULTI_IMPLEMENTOR,
18
+ _CONF_SINGLE_IMPLEMENTOR,
19
+ _CONF_SPRING_PRIMARY,
20
+ _CONF_SPRING_QUALIFIER,
21
+ _MOCK_ALLOCATION_TYPE,
22
+ )
23
+ from adl_drift.lang.java.resolution.model import (
24
+ BeanCandidate as _BeanCandidate,
25
+ DINarrowResult as _DINarrowResult,
26
+ )
27
+
28
+ class DispatchMixin:
29
+ def _is_concrete_class(self, class_id: str) -> bool:
30
+ """True iff `class_id` is a concrete type able to carry a runtime
31
+ method body — a `class`/`record`/`enum` that is not `abstract`. An
32
+ interface or `abstract class` is the *source* of dispatch ambiguity,
33
+ never a fan-out target."""
34
+ node = self.builder._nodes.get(class_id)
35
+ if node is None or node.node_type != NodeType.CLASS:
36
+ return False
37
+ kind = node.metadata.get("java_type_kind")
38
+ if kind not in _CONCRETE_TYPE_KINDS:
39
+ return False
40
+ return not bool(node.metadata.get("java_is_abstract", False))
41
+
42
+ def _is_abstract_method(self, func_id: str) -> bool:
43
+ node = self.builder._nodes.get(func_id)
44
+ return bool(node is not None and node.metadata.get("java_is_abstract", False))
45
+
46
+ def _is_abstract_target(self, target: Union[str, List[str]]) -> bool:
47
+ """True iff the P4.4-resolved target is (entirely) an abstract method —
48
+ the signal that a concrete runtime implementation is elsewhere. A list
49
+ target (an unresolved interface-level overload tie) counts only when
50
+ *every* candidate is abstract, so a mixed tie with a concrete default
51
+ keeps its direct edges."""
52
+ ids = target if isinstance(target, list) else [target]
53
+ return bool(ids) and all(self._is_abstract_method(t) for t in ids)
54
+
55
+ def _abstract_fan_out(
56
+ self, recv_class_id: str, resolved_target: Union[str, List[str]],
57
+ method_name: str, arg_count: int, arg_types: Optional[List[str]],
58
+ caller_ctx: "_ImportContext", obj_node, source: bytes,
59
+ enclosing_class_id: Optional[str], own_param_types: Dict[str, str],
60
+ local_types: "_LocalScopes",
61
+ ) -> Optional[Tuple[Union[str, List[str]], float, Dict[str, object]]]:
62
+ """The abstract-receiver branch (deep-dive §6.3). Returns a
63
+ `(target(s), confidence, metadata)` triple that *replaces* the P4.4
64
+ interface-method edge, or None to keep it (strict mode, a concrete
65
+ target, or zero concrete implementors — precision-first: never flood).
66
+
67
+ Order of evidence, strongest first: Spring DI narrowing off the
68
+ receiver field's injection annotations, then the raw implementor
69
+ fan-out (single implementor -> high; many -> low). The precision↔recall
70
+ dial (`self._fanout_mode`) governs the raw fan-out: `strict` keeps only
71
+ the interface edge, `balanced` caps emission above `self._fanout_cap`,
72
+ `recall` emits every implementor."""
73
+ if not self._is_abstract_target(resolved_target):
74
+ return None
75
+ if self._fanout_mode == "strict":
76
+ return None
77
+
78
+ # 1. Exact local allocation. Keep the declared receiver type for
79
+ # static resolution; use Interface x = new Impl() only to narrow the
80
+ # runtime expansion. Evidence expires at the first reassignment.
81
+ if obj_node.type == "identifier":
82
+ local_name = self._text(obj_node, source)
83
+ allocation_type = local_types.allocation_type_at(
84
+ local_name, obj_node.start_byte,
85
+ )
86
+ if allocation_type == _MOCK_ALLOCATION_TYPE:
87
+ # A Mockito mock/spy has no real declared implementation to
88
+ # dispatch to — keep the honest abstract-declaration edge
89
+ # rather than guessing an unrelated project class that
90
+ # happens to implement the same interface (P5 finding #2).
91
+ return None
92
+ if allocation_type is not None:
93
+ allocation_class_id = self._resolve_declared_type(
94
+ allocation_type, caller_ctx.module, caller_ctx.import_map,
95
+ caller_ctx.wildcard_packages, enclosing_class_id,
96
+ )
97
+ if (
98
+ allocation_class_id is not None
99
+ and self._is_concrete_class(allocation_class_id)
100
+ and (
101
+ allocation_class_id == recv_class_id
102
+ or recv_class_id in self._class_ancestors(allocation_class_id)
103
+ )
104
+ ):
105
+ allocation_target, _ = self._resolve_own_or_ancestor(
106
+ allocation_class_id, method_name, arg_count, arg_types,
107
+ _CONF_SINGLE_IMPLEMENTOR, _CONF_SINGLE_IMPLEMENTOR,
108
+ caller_ctx,
109
+ )
110
+ if (
111
+ isinstance(allocation_target, str)
112
+ and not self._is_abstract_method(allocation_target)
113
+ ):
114
+ recv_node = self.builder._nodes.get(recv_class_id)
115
+ declared = (
116
+ recv_node.name if recv_node is not None else recv_class_id
117
+ )
118
+ return allocation_target, _CONF_SINGLE_IMPLEMENTOR, {
119
+ "declared_receiver_type": declared,
120
+ "strategy": "single_implementor",
121
+ "allocation_narrowed": True,
122
+ }
123
+
124
+ # 2. Unanimous own-class field allocation. This never consults
125
+ # sibling subclasses, so an inherited polymorphic test fixture keeps
126
+ # its full runtime expansion.
127
+ field_name: Optional[str] = None
128
+ if enclosing_class_id is not None:
129
+ if obj_node.type == "identifier":
130
+ candidate = self._text(obj_node, source)
131
+ if (
132
+ local_types.type_at(candidate, obj_node.start_byte) is None
133
+ and candidate not in own_param_types
134
+ and candidate in self._all_field_types(enclosing_class_id)
135
+ ):
136
+ field_name = candidate
137
+ elif obj_node.type == "field_access":
138
+ inner = obj_node.child_by_field_name("object")
139
+ field = obj_node.child_by_field_name("field")
140
+ if inner is not None and inner.type == "this" and field is not None:
141
+ field_name = self._text(field, source)
142
+ field_allocation_id = (
143
+ self._field_allocation_types.get(enclosing_class_id, {}).get(field_name)
144
+ if enclosing_class_id is not None and field_name is not None else None
145
+ )
146
+ if field_allocation_id == _MOCK_ALLOCATION_TYPE:
147
+ # Same fact as step 1, for a field-declared mock/spy receiver
148
+ # (`ConfigurationParameters configParams = mock();`).
149
+ return None
150
+ if (
151
+ field_allocation_id is not None
152
+ and self._is_concrete_class(field_allocation_id)
153
+ and (
154
+ field_allocation_id == recv_class_id
155
+ or recv_class_id in self._class_ancestors(field_allocation_id)
156
+ )
157
+ ):
158
+ field_target, _ = self._resolve_own_or_ancestor(
159
+ field_allocation_id, method_name, arg_count, arg_types,
160
+ _CONF_SINGLE_IMPLEMENTOR, _CONF_SINGLE_IMPLEMENTOR, caller_ctx,
161
+ )
162
+ if isinstance(field_target, str) and not self._is_abstract_method(field_target):
163
+ recv_node = self.builder._nodes.get(recv_class_id)
164
+ declared = recv_node.name if recv_node is not None else recv_class_id
165
+ return field_target, _CONF_SINGLE_IMPLEMENTOR, {
166
+ "declared_receiver_type": declared,
167
+ "strategy": "single_implementor",
168
+ "field_allocation_narrowed": True,
169
+ }
170
+
171
+ # 3. Spring DI narrowing (only meaningful for an injected field).
172
+ field_node_id = self._receiver_field_node_id(
173
+ obj_node, source, enclosing_class_id, local_types, own_param_types,
174
+ )
175
+ if field_node_id is not None and self._is_injected_field(field_node_id):
176
+ di = self._di_narrow(
177
+ recv_class_id, field_node_id, method_name, arg_count, arg_types, caller_ctx,
178
+ )
179
+ if di is not None:
180
+ if di.target is None:
181
+ return None
182
+ recv_node = self.builder._nodes.get(recv_class_id)
183
+ declared = recv_node.name if recv_node is not None else recv_class_id
184
+ return di.target, di.confidence, {
185
+ "declared_receiver_type": declared, "strategy": di.strategy,
186
+ }
187
+
188
+ # 4. Raw implementor fan-out.
189
+ return self._raw_implementor_fan_out(
190
+ recv_class_id, method_name, arg_count, arg_types, caller_ctx,
191
+ )
192
+
193
+ def _raw_implementor_fan_out(
194
+ self, recv_class_id: str, method_name: str, arg_count: int,
195
+ arg_types: Optional[List[str]], caller_ctx: "_ImportContext",
196
+ ) -> Optional[Tuple[Union[str, List[str]], float, Dict[str, object]]]:
197
+ """The mode-gated raw implementor fan-out, shared by the typed-receiver
198
+ branch (via `_abstract_fan_out`, after DI narrowing) and the `this`/bare
199
+ + `super` branches (which have no injectable field, so they call this
200
+ directly). Resolves `method_name` on every concrete subtype of
201
+ `recv_class_id` and returns `(target(s), confidence, metadata)`, or None
202
+ to keep the honest abstract-declaration edge: `strict` mode, zero
203
+ concrete implementors, or — in `balanced` mode — more implementors than
204
+ `self._fanout_cap` (precision-first: never flood blast radius). Metadata
205
+ carries `declared_receiver_type` + `strategy` so impact/MCP can explain
206
+ the fanned-out edges (deep-dive §8.4). Callers must have already
207
+ established the resolved target is abstract (`_is_abstract_target`)."""
208
+ if self._fanout_mode == "strict":
209
+ return None
210
+ recv_node = self.builder._nodes.get(recv_class_id)
211
+ declared = recv_node.name if recv_node is not None else recv_class_id
212
+ base_meta: Dict[str, object] = {"declared_receiver_type": declared}
213
+ targets = self._fan_out_implementors(
214
+ recv_class_id, method_name, arg_count, arg_types, caller_ctx,
215
+ )
216
+ if not targets:
217
+ return None # interface-only: keep the honest declaration edge.
218
+ telemetry = getattr(self, "fanout_telemetry", None)
219
+ if isinstance(telemetry, dict):
220
+ telemetry.setdefault("candidate_counts", []).append(len(targets))
221
+ if len(targets) == 1:
222
+ if isinstance(telemetry, dict):
223
+ telemetry.setdefault("emitted_counts", []).append(1)
224
+ return targets[0], _CONF_SINGLE_IMPLEMENTOR, {
225
+ **base_meta, "strategy": "single_implementor",
226
+ }
227
+ if self._fanout_mode == "balanced" and len(targets) > self._fanout_cap:
228
+ # Precision-first: too many implementors to name honestly — keep
229
+ # the single interface edge rather than flooding blast radius.
230
+ if isinstance(telemetry, dict):
231
+ telemetry["cap_skipped"] = int(telemetry.get("cap_skipped", 0)) + 1
232
+ telemetry.setdefault("emitted_counts", []).append(0)
233
+ return None
234
+ if isinstance(telemetry, dict):
235
+ telemetry.setdefault("emitted_counts", []).append(len(targets))
236
+ return targets, _CONF_MULTI_IMPLEMENTOR, {
237
+ **base_meta, "strategy": "interface_fanout",
238
+ }
239
+
240
+ def _fan_out_implementors(
241
+ self, root_class_id: str, method_name: str, arg_count: int,
242
+ arg_types: Optional[List[str]], caller_ctx: "_ImportContext",
243
+ ) -> List[str]:
244
+ """Ordered, de-duplicated concrete method targets for the fan-out: for
245
+ every concrete transitive subtype of `root_class_id` (interface or
246
+ abstract class), resolve `method_name` on that subtype the normal P4.4
247
+ way and collect the concrete override it lands on. A subtype that does
248
+ not resolve the method (or resolves it only to another abstract
249
+ declaration) contributes nothing — never a guess."""
250
+ targets: List[str] = []
251
+ seen: Set[str] = set()
252
+ for impl in sorted(self._subtypes.get(root_class_id, ())):
253
+ if not self._is_concrete_class(impl):
254
+ continue
255
+ mt, _ = self._resolve_own_or_ancestor(
256
+ impl, method_name, arg_count, arg_types,
257
+ _CONF_SINGLE_IMPLEMENTOR, _CONF_SINGLE_IMPLEMENTOR, caller_ctx,
258
+ )
259
+ for tid in (mt if isinstance(mt, list) else [mt] if mt else []):
260
+ if tid in seen or self._is_abstract_method(tid):
261
+ continue
262
+ seen.add(tid)
263
+ targets.append(tid)
264
+ return targets
265
+
266
+ # -- DI narrowing (Spring) ------------------------------------------------
267
+
268
+ def _receiver_field_node_id(
269
+ self, obj_node, source: bytes, enclosing_class_id: Optional[str],
270
+ local_types: "_LocalScopes", param_types: Dict[str, str],
271
+ ) -> Optional[str]:
272
+ """The VARIABLE node id of the field a receiver reads, when the receiver
273
+ is genuinely a field of the enclosing class — a bare `field` identifier
274
+ that is neither a local nor a parameter, or an explicit `this.field`.
275
+ Anything else (a local, a parameter, a static-class receiver, a chained
276
+ expression) is not an injectable field and returns None."""
277
+ if enclosing_class_id is None:
278
+ return None
279
+ field_name: Optional[str] = None
280
+ if obj_node.type == "identifier":
281
+ name = self._text(obj_node, source)
282
+ if local_types.type_at(name, obj_node.start_byte) is not None:
283
+ return None # a local, not a field
284
+ if name in param_types:
285
+ return None # a parameter, not a field
286
+ field_name = name
287
+ elif obj_node.type == "field_access":
288
+ inner = obj_node.child_by_field_name("object")
289
+ field_node = obj_node.child_by_field_name("field")
290
+ if inner is not None and inner.type == "this" and field_node is not None:
291
+ field_name = self._text(field_node, source)
292
+ if field_name is None:
293
+ return None
294
+ return self._all_field_node_ids(enclosing_class_id).get(field_name)
295
+
296
+ def _all_field_node_ids(self, class_id: str) -> Dict[str, str]:
297
+ """`{field_name: field VARIABLE node id}` for `class_id` merged with
298
+ every ancestor's fields, nearer declarations winning — the node-id
299
+ analogue of `_all_field_types` (an injected field may be declared on a
300
+ base class)."""
301
+ merged: Dict[str, str] = {}
302
+ for anc in reversed(self._class_ancestors(class_id)):
303
+ merged.update(self._field_nodes.get(anc, {}))
304
+ merged.update(self._field_nodes.get(class_id, {}))
305
+ return merged
306
+
307
+ def _is_injected_field(self, field_node_id: str) -> bool:
308
+ node = self.builder._nodes.get(field_node_id)
309
+ if node is None:
310
+ return False
311
+ return field_node_id in self._di_injected_fields or any(
312
+ self._di_annotation_simple_name(d, node.file_path) in JAVA_INJECTION_ANNOTATIONS
313
+ for d in node.decorators
314
+ )
315
+
316
+ def _field_qualifier(self, field_node_id: str) -> Optional[str]:
317
+ """The `@Qualifier("name")` (or `@Resource(name="…")`-style single
318
+ string arg) requested on an injected field, or None."""
319
+ node = self.builder._nodes.get(field_node_id)
320
+ if node is None:
321
+ return None
322
+ for deco in node.decorators:
323
+ simple = self._di_annotation_simple_name(deco, node.file_path)
324
+ if simple == "Resource":
325
+ named = self._annotation_named_string_arg(deco, "name")
326
+ if named:
327
+ return named
328
+ return node.name
329
+ direct = self._qualifier_from_decorators(node.decorators, node.file_path)
330
+ if direct:
331
+ return direct
332
+ injected = self._di_injected_fields.get(field_node_id)
333
+ if injected is not None:
334
+ return injected.qualifier
335
+ return None
336
+
337
+ def _qualifier_from_decorators(
338
+ self, decorators: List[str], file_path: str,
339
+ ) -> Optional[str]:
340
+ for deco in decorators:
341
+ simple = self._di_annotation_simple_name(deco, file_path)
342
+ if simple == "Qualifier":
343
+ arg = self._annotation_string_arg(deco)
344
+ if arg:
345
+ return arg
346
+ elif simple == "Resource":
347
+ arg = self._annotation_named_string_arg(deco, "name")
348
+ if arg:
349
+ return arg
350
+ return None
351
+
352
+ def _di_narrow(
353
+ self, interface_id: str, field_node_id: str, method_name: str,
354
+ arg_count: int, arg_types: Optional[List[str]], caller_ctx: "_ImportContext",
355
+ ) -> Optional[_DINarrowResult]:
356
+ """Narrow an injected interface field to one concrete Spring bean
357
+ (deep-dive §7.3). Returns a target when the evidence names exactly one
358
+ bean — a matching `@Qualifier` (0.95), a sole `@Component`-family
359
+ implementor (0.85), or a `@Primary` among several (0.90). An explicit
360
+ but unresolved/ambiguous qualifier returns a blocked result so callers
361
+ do not fall back to unrelated DI rules."""
362
+ candidates = self._bean_candidates(interface_id)
363
+ if not candidates:
364
+ return None
365
+ qualifier = self._field_qualifier(field_node_id)
366
+ if qualifier is not None:
367
+ matches = [
368
+ c for c in candidates
369
+ if qualifier in c.qualifiers or c.bean_name == qualifier
370
+ ]
371
+ if len(matches) == 1:
372
+ tgt = self._resolve_bean_method(
373
+ matches[0], method_name, arg_count, arg_types, caller_ctx,
374
+ )
375
+ if tgt is not None:
376
+ return _DINarrowResult(
377
+ tgt, _CONF_SPRING_QUALIFIER, "spring_qualifier",
378
+ )
379
+ if len(matches) == 0:
380
+ return _DINarrowResult(None, 0.0, "spring_qualifier_unresolved")
381
+ return _DINarrowResult(None, 0.0, "spring_qualifier_ambiguous")
382
+ if len(candidates) == 1:
383
+ tgt = self._resolve_bean_method(
384
+ candidates[0], method_name, arg_count, arg_types, caller_ctx,
385
+ )
386
+ if tgt is not None:
387
+ return _DINarrowResult(
388
+ tgt, _CONF_DI_SINGLE_COMPONENT, "di_single_component",
389
+ )
390
+ primaries = [c for c in candidates if c.primary]
391
+ if len(primaries) == 1:
392
+ tgt = self._resolve_bean_method(
393
+ primaries[0], method_name, arg_count, arg_types, caller_ctx,
394
+ )
395
+ if tgt is not None:
396
+ return _DINarrowResult(tgt, _CONF_SPRING_PRIMARY, "spring_primary")
397
+ return None
398
+
399
+ def _resolve_bean_method(
400
+ self, bean: "_BeanCandidate", method_name: str, arg_count: int,
401
+ arg_types: Optional[List[str]], caller_ctx: "_ImportContext",
402
+ ) -> Optional[str]:
403
+ """Resolve `method_name` on a bean's concrete type to a single method
404
+ node id (None if it does not resolve to exactly one concrete method)."""
405
+ mt, _ = self._resolve_own_or_ancestor(
406
+ bean.type_id, method_name, arg_count, arg_types,
407
+ _CONF_SINGLE_IMPLEMENTOR, _CONF_SINGLE_IMPLEMENTOR, caller_ctx,
408
+ )
409
+ if isinstance(mt, str) and not self._is_abstract_method(mt):
410
+ return mt
411
+ return None
412
+
413
+ def _bean_candidates(self, interface_id: str) -> List["_BeanCandidate"]:
414
+ """Every concrete Spring bean that can satisfy `interface_id`: its
415
+ `@Component`-family concrete implementors, plus any `@Bean` factory
416
+ method producing one of its implementors."""
417
+ subtypes = self._subtypes.get(interface_id, set())
418
+ out: List[_BeanCandidate] = []
419
+ for impl in sorted(subtypes):
420
+ if not self._is_concrete_class(impl):
421
+ continue
422
+ info = self._component_info(impl)
423
+ if info is not None:
424
+ out.append(info)
425
+ for bm in self._bean_method_candidates:
426
+ if bm.type_id in subtypes:
427
+ out.append(bm)
428
+ return out
429
+
430
+ def _component_info(self, class_id: str) -> Optional["_BeanCandidate"]:
431
+ """A `_BeanCandidate` for a class iff it carries a `@Component`-family
432
+ annotation, else None. Bean name comes from the annotation's string arg
433
+ (`@Service("fast")`) or the decapitalized simple class name; qualifiers
434
+ gather any `@Qualifier` plus the bean name; `@Primary` sets the flag."""
435
+ node = self.builder._nodes.get(class_id)
436
+ if node is None or not node.decorators:
437
+ return None
438
+ names = {self._di_annotation_simple_name(d, node.file_path): d for d in node.decorators}
439
+ if not (JAVA_COMPONENT_ANNOTATIONS & set(names)):
440
+ return None
441
+ bean_name, qualifiers, primary = self._bean_metadata_from_decorators(
442
+ node.decorators, node.name, node.file_path,
443
+ )
444
+ return _BeanCandidate(class_id, bean_name, qualifiers, primary, source="component")
445
+
446
+ def _bean_metadata_from_decorators(
447
+ self, decorators: List[str], type_or_method_name: str, file_path: str,
448
+ ) -> Tuple[Optional[str], FrozenSet[str], bool]:
449
+ """`(bean_name, qualifiers, primary)` distilled from a class's/`@Bean`
450
+ method's annotation list. The explicit bean name is the string arg of a
451
+ component/`@Bean` annotation if given, else the decapitalized name;
452
+ qualifiers include any `@Qualifier` value *and* the bean name itself (a
453
+ field `@Qualifier("stripeGateway")` matches a bean named after its
454
+ class); `@Primary` presence sets the flag."""
455
+ primary = False
456
+ qualifiers: Set[str] = set()
457
+ explicit_name: Optional[str] = None
458
+ for deco in decorators:
459
+ simple = self._di_annotation_simple_name(deco, file_path)
460
+ if simple == "Primary":
461
+ primary = True
462
+ elif simple == "Qualifier":
463
+ arg = self._annotation_string_arg(deco)
464
+ if arg:
465
+ qualifiers.add(arg)
466
+ elif simple in JAVA_COMPONENT_ANNOTATIONS or simple == "Bean":
467
+ arg = self._annotation_string_arg(deco)
468
+ if arg:
469
+ explicit_name = arg
470
+ bean_name = explicit_name or self._decapitalize(type_or_method_name)
471
+ if bean_name:
472
+ qualifiers.add(bean_name)
473
+ return bean_name, frozenset(qualifiers), primary
474
+
475
+ @staticmethod
476
+ def _decapitalize(name: str) -> str:
477
+ """Spring's default bean name from a class name: the simple name with
478
+ its first letter lowercased (`StripeGateway` -> `stripeGateway`)."""
479
+ return name[:1].lower() + name[1:] if name else name
480
+
481
+ def _di_annotation_simple_name(self, deco: str, file_path: str) -> str:
482
+ """Simple annotation name from decorator text (`@lombok.Getter` ->
483
+ `Getter`, `@Qualifier("x")` -> `Qualifier`, reusing the builder's own
484
+ dotted-name + argument-stripping parser) iff it is eligible as
485
+ Spring/Jakarta DI evidence — "" when it instead resolves to a
486
+ project-local `@interface` declaration shadowing that well-known name
487
+ (F-02 fix, `_is_local_annotation`), the DI-evidence analogue of the
488
+ Lombok-stub guard `_decorator_is_local_annotation` already applies. A
489
+ project's own `@interface Service {}` (or `Autowired`/`Qualifier`/
490
+ `Primary`/`Bean`/`Component`/...) must never be read as Spring's —
491
+ every call site that treats a decorator as DI/component evidence
492
+ (constructor- and setter-injection indexing, field/param injection
493
+ checks, `@Qualifier`/`@Primary` narrowing, `@Component`-family
494
+ detection, `@Bean` factory candidates) goes through this rather than
495
+ a bare simple-name comparison for that reason."""
496
+ dname = self.builder._decorator_name(deco)
497
+ if not dname:
498
+ return ""
499
+ simple = dname.split(".")[-1]
500
+ if self._is_local_annotation(dname, simple, file_path):
501
+ return ""
502
+ fqn = self._resolved_di_annotation_fqn(dname, simple, file_path)
503
+ if fqn not in JAVA_DI_ANNOTATION_FQNS:
504
+ return ""
505
+ return simple
506
+
507
+ def _resolved_di_annotation_fqn(
508
+ self, dname: str, simple: str, file_path: str,
509
+ ) -> Optional[str]:
510
+ if "." in dname:
511
+ return dname
512
+ ctx = self._file_ctx.get(file_path)
513
+ if ctx is None:
514
+ return None
515
+ imported = ctx.import_map.get(simple)
516
+ if imported is not None:
517
+ return imported
518
+ own_fqn = simple if ctx.module == DEFAULT_PACKAGE else f"{ctx.module}.{simple}"
519
+ if own_fqn in JAVA_DI_ANNOTATION_FQNS:
520
+ return own_fqn
521
+ for pkg in ctx.wildcard_packages:
522
+ fqn = f"{pkg}.{simple}"
523
+ if fqn in JAVA_DI_ANNOTATION_FQNS:
524
+ return fqn
525
+ return None
526
+
527
+ @staticmethod
528
+ def _annotation_string_arg(deco: str) -> Optional[str]:
529
+ """The first string-literal argument of an annotation, or None
530
+ (`@Qualifier("stripe")` -> `stripe`)."""
531
+ m = _ANNOTATION_STRING_ARG.search(deco)
532
+ return m.group(1) if m is not None else None
533
+
534
+ @staticmethod
535
+ def _annotation_named_string_arg(deco: str, arg_name: str) -> Optional[str]:
536
+ """A named string-literal annotation argument, or None
537
+ (`@Resource(name = "stripe")` -> `stripe`)."""
538
+ pattern = re.compile(rf"\b{re.escape(arg_name)}\s*=\s*\"((?:[^\"\\]|\\.)*)\"")
539
+ m = pattern.search(deco)
540
+ return m.group(1) if m is not None else None