@codefast/di 0.5.0-canary.7 → 0.5.0-canary.9

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 (114) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +6 -2
  3. package/dist/binding.d.ts +85 -24
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +55 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/constructor-type.d.ts +4 -5
  8. package/dist/constructor-type.d.ts.map +1 -1
  9. package/dist/container/binding-builders.d.ts +32 -11
  10. package/dist/container/binding-builders.d.ts.map +1 -1
  11. package/dist/container/binding-builders.js +144 -192
  12. package/dist/container/binding-builders.js.map +1 -1
  13. package/dist/container/container.d.ts.map +1 -1
  14. package/dist/container/container.js +141 -201
  15. package/dist/container/container.js.map +1 -1
  16. package/dist/decorators/inject.d.ts +2 -4
  17. package/dist/decorators/inject.d.ts.map +1 -1
  18. package/dist/decorators/inject.js.map +1 -1
  19. package/dist/errors.d.ts +24 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +30 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/introspection/inspector.d.ts +0 -1
  28. package/dist/introspection/inspector.d.ts.map +1 -1
  29. package/dist/introspection/inspector.js +3 -8
  30. package/dist/introspection/inspector.js.map +1 -1
  31. package/dist/metadata/metadata-keys.d.ts +3 -6
  32. package/dist/metadata/metadata-keys.d.ts.map +1 -1
  33. package/dist/metadata/metadata-keys.js +3 -6
  34. package/dist/metadata/metadata-keys.js.map +1 -1
  35. package/dist/registry.d.ts +14 -2
  36. package/dist/registry.d.ts.map +1 -1
  37. package/dist/registry.js +81 -78
  38. package/dist/registry.js.map +1 -1
  39. package/dist/resolution/activation-need.d.ts +27 -0
  40. package/dist/resolution/activation-need.d.ts.map +1 -0
  41. package/dist/resolution/activation-need.js +68 -0
  42. package/dist/resolution/activation-need.js.map +1 -0
  43. package/dist/resolution/binding-lookup-cache.d.ts +41 -0
  44. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -0
  45. package/dist/resolution/binding-lookup-cache.js +118 -0
  46. package/dist/resolution/binding-lookup-cache.js.map +1 -0
  47. package/dist/resolution/binding-scope.d.ts +5 -2
  48. package/dist/resolution/binding-scope.d.ts.map +1 -1
  49. package/dist/resolution/binding-scope.js +6 -17
  50. package/dist/resolution/binding-scope.js.map +1 -1
  51. package/dist/resolution/binding-select.d.ts +8 -1
  52. package/dist/resolution/binding-select.d.ts.map +1 -1
  53. package/dist/resolution/binding-select.js +14 -36
  54. package/dist/resolution/binding-select.js.map +1 -1
  55. package/dist/resolution/class-introspector.d.ts +27 -0
  56. package/dist/resolution/class-introspector.d.ts.map +1 -0
  57. package/dist/resolution/class-introspector.js +60 -0
  58. package/dist/resolution/class-introspector.js.map +1 -0
  59. package/dist/resolution/diagnostics.d.ts +41 -0
  60. package/dist/resolution/diagnostics.d.ts.map +1 -0
  61. package/dist/resolution/diagnostics.js +18 -0
  62. package/dist/resolution/diagnostics.js.map +1 -0
  63. package/dist/resolution/environment.d.ts +48 -1
  64. package/dist/resolution/environment.d.ts.map +1 -1
  65. package/dist/resolution/environment.js +134 -5
  66. package/dist/resolution/environment.js.map +1 -1
  67. package/dist/resolution/instantiation-plan.d.ts +15 -15
  68. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  69. package/dist/resolution/instantiation-plan.js +69 -49
  70. package/dist/resolution/instantiation-plan.js.map +1 -1
  71. package/dist/resolution/lifecycle.d.ts +2 -0
  72. package/dist/resolution/lifecycle.d.ts.map +1 -1
  73. package/dist/resolution/lifecycle.js +60 -62
  74. package/dist/resolution/lifecycle.js.map +1 -1
  75. package/dist/resolution/resolution-path.d.ts +84 -17
  76. package/dist/resolution/resolution-path.d.ts.map +1 -1
  77. package/dist/resolution/resolution-path.js +68 -23
  78. package/dist/resolution/resolution-path.js.map +1 -1
  79. package/dist/resolution/resolve-options.d.ts +41 -4
  80. package/dist/resolution/resolve-options.d.ts.map +1 -1
  81. package/dist/resolution/resolve-options.js +25 -1
  82. package/dist/resolution/resolve-options.js.map +1 -1
  83. package/dist/resolution/resolver.d.ts +33 -10
  84. package/dist/resolution/resolver.d.ts.map +1 -1
  85. package/dist/resolution/resolver.js +471 -830
  86. package/dist/resolution/resolver.js.map +1 -1
  87. package/dist/resolution/scope.d.ts +11 -15
  88. package/dist/resolution/scope.d.ts.map +1 -1
  89. package/dist/resolution/scope.js +55 -43
  90. package/dist/resolution/scope.js.map +1 -1
  91. package/package.json +10 -106
  92. package/src/binding.ts +146 -24
  93. package/src/constructor-type.ts +4 -5
  94. package/src/container/binding-builders.ts +184 -284
  95. package/src/container/container.ts +161 -221
  96. package/src/decorators/inject.ts +3 -5
  97. package/src/errors.ts +38 -0
  98. package/src/index.ts +4 -1
  99. package/src/introspection/inspector.ts +3 -9
  100. package/src/metadata/metadata-keys.ts +3 -6
  101. package/src/registry.ts +90 -94
  102. package/src/resolution/activation-need.ts +85 -0
  103. package/src/resolution/binding-lookup-cache.ts +148 -0
  104. package/src/resolution/binding-scope.ts +6 -17
  105. package/src/resolution/binding-select.ts +15 -39
  106. package/src/resolution/class-introspector.ts +74 -0
  107. package/src/resolution/diagnostics.ts +43 -0
  108. package/src/resolution/environment.ts +181 -5
  109. package/src/resolution/instantiation-plan.ts +116 -64
  110. package/src/resolution/lifecycle.ts +69 -62
  111. package/src/resolution/resolution-path.ts +122 -43
  112. package/src/resolution/resolve-options.ts +51 -4
  113. package/src/resolution/resolver.ts +649 -1081
  114. package/src/resolution/scope.ts +58 -47
@@ -1,7 +1,6 @@
1
- import type { Binding, BindingSlot } from "#/binding";
2
- import type { ConstructorInvocation } from "#/constructor-type";
1
+ import type { Binding, ConstantBinding, DynamicAsyncBinding, DynamicBinding } from "#/binding";
2
+ import { NO_INSTANCE } from "#/binding";
3
3
  import type { Container } from "#/container/container";
4
- import type { InjectionDescriptor } from "#/decorators/inject";
5
4
  import {
6
5
  AsyncActivationError,
7
6
  AsyncResolutionError,
@@ -12,43 +11,49 @@ import {
12
11
  NoMatchingBindingError,
13
12
  TokenNotBoundError,
14
13
  } from "#/errors";
15
- import type { ConstructorMetadata, MetadataReader } from "#/metadata/metadata-types";
14
+ import type { MetadataReader, ParamMetadata } from "#/metadata/metadata-types";
16
15
  import type { BindingRegistry } from "#/registry";
17
- import { selectAllBindings, selectBinding } from "#/resolution/binding-select";
16
+ import { ActivationNeedCache } from "#/resolution/activation-need";
17
+ import type { DefaultLookupEntry } from "#/resolution/binding-lookup-cache";
18
+ import { BindingLookupCache } from "#/resolution/binding-lookup-cache";
19
+ import { matchesSlot, selectAllBindings, selectBinding } from "#/resolution/binding-select";
20
+ import { ClassIntrospector } from "#/resolution/class-introspector";
21
+ import type { ResolutionDiagnostics } from "#/resolution/diagnostics";
18
22
  import type { ResolverCallbacks } from "#/resolution/environment";
19
- import { buildResolutionFrame, DefaultResolutionContext, runWithContainer } from "#/resolution/environment";
23
+ import {
24
+ AsyncCascadeContext,
25
+ AsyncLevelContext,
26
+ buildResolutionFrame,
27
+ DefaultResolutionContext,
28
+ } from "#/resolution/environment";
20
29
  import { InstantiationPlanCompiler, PLAN_RETRY } from "#/resolution/instantiation-plan";
21
30
  import type { LifecycleManager } from "#/resolution/lifecycle";
22
- import { enterResolutionPath, exitResolutionPath } from "#/resolution/resolution-path";
23
- import { injectionSlotToResolveOptions } from "#/resolution/resolve-options";
31
+ import type { BranchDepth, OwnedBranchPath } from "#/resolution/resolution-path";
32
+ import {
33
+ branchDepthOf,
34
+ enterResolutionPath,
35
+ extendResolutionBranch,
36
+ extendResolutionStackBranch,
37
+ ROOT_BRANCH,
38
+ UNOWNED_BRANCH,
39
+ } from "#/resolution/resolution-path";
40
+ import type { DependencySlot } from "#/resolution/resolve-options";
41
+ import { injectionSlotToResolveOptions, isNameOnlyOptions, singleTagOnlyOf } from "#/resolution/resolve-options";
24
42
  import type { ScopeManager } from "#/resolution/scope";
25
- import { SINGLETON_MISS } from "#/resolution/scope";
26
43
  import type { Token } from "#/token";
27
44
  import { tokenName } from "#/token";
28
45
  import type {
29
46
  ActivationHandler,
30
47
  BindingIdentifier,
31
- BindingScope,
32
- BindingTag,
33
48
  ConstraintContext,
34
49
  Constructor,
35
50
  ResolutionFrame,
36
51
  ResolveOptions,
37
52
  } from "#/types";
38
53
 
39
- // Terminal result of the options-less lookup fast lane — alias hops already folded.
40
- interface DefaultLookupEntry {
41
- readonly binding: Binding;
42
- readonly owner: DependencyResolver;
43
- }
44
-
45
- // Fast-lane alias folding gives up past this many hops and defers to the resolve()
46
- // loop, whose Set-based traversal detects genuine cycles exactly (no arbitrary cap).
47
- const ALIAS_HOP_LIMIT = 32;
48
-
49
- type BindingWithScope = Binding & { scope: BindingScope };
50
54
  const EMPTY_STRING_LIST: ReadonlyArray<string> = [];
51
55
  const EMPTY_FRAME_LIST: ReadonlyArray<ResolutionFrame> = [];
56
+ const EMPTY_PARAM_LIST: ReadonlyArray<ParamMetadata> = [];
52
57
  const ROOT_CONSTRAINT_CONTEXT = {
53
58
  resolutionPath: EMPTY_STRING_LIST,
54
59
  resolutionStack: EMPTY_FRAME_LIST,
@@ -60,53 +65,25 @@ const ROOT_CONSTRAINT_CONTEXT = {
60
65
  /**
61
66
  * @since 0.3.16-canary.0
62
67
  */
63
- export class DependencyResolver {
68
+ export class DependencyResolver implements ResolverCallbacks {
64
69
  readonly #syncResolutionContextPool: Array<DefaultResolutionContext> = [];
65
- // Cycle detection for the sync transient-dynamic lane lives on `binding.inFlight` — see the
66
- // field's doc comment in binding.ts. It is an O(1) field read with no hashing, no path scan and
67
- // no side table to allocate or grow, so the lane needs no depth split.
68
- // Shared-context state for the async transient-dynamic lane.
69
- //
70
- // Every level of a SEQUENTIAL async chain shares the same resolutionPath and resolutionStack
71
- // arrays (passed by reference through ctx.resolveAsync), and the context stores references
72
- // rather than snapshots, so one DefaultResolutionContext can serve the whole chain — the arrays
73
- // reflect the current state automatically as levels push and pop.
74
- //
75
- // #asyncChainCtx: the shared context, created on first use and reset at each new root call.
76
- // Inner levels of the same chain reuse it with zero setup.
77
- // #asyncChainCtxPath: identity of the resolutionPath array owning the shared context — same
78
- // reference means an inner level of that chain, a different one means a concurrent chain
79
- // (e.g. Promise.all) which gets its own context instead.
80
- // #asyncChainActiveLevels: active levels of the OWNING chain, so the path pointer is released
81
- // when the last one settles. Concurrent fallback calls are not counted.
82
- //
83
- // The method is NOT declared async: that would allocate an async state machine and an implicit
84
- // promise per level, where a `.then(settle, settle)` side listener costs neither.
85
- #asyncChainCtx: DefaultResolutionContext | undefined;
86
- #asyncChainCtxPath: Array<string> | undefined;
87
- #asyncChainActiveLevels = 0;
88
- // Settle callback shared by every level of the owning async chain: all of them pop the same
89
- // resolutionPath and decrement the same counter, so one closure serves the whole chain instead
90
- // of allocating one per level (the async lane's dominant per-level allocation).
91
- #asyncChainSettle: (() => void) | undefined;
92
- readonly #classHasPostConstruct = new WeakMap<Constructor, boolean>();
93
- readonly #classNeedsActiveContainer = new WeakMap<Constructor, boolean>();
94
- readonly #classConstructorMetadata = new WeakMap<Constructor, ConstructorMetadata | null>();
95
- readonly #activationNeedByBindingId = new Map<BindingIdentifier, boolean>();
96
- #activationCacheVersion = -1;
97
- // Options-less lookup memo across the parent chain: token → terminal {binding, owner}
98
- // (alias hops folded). `null` = token must take the slow lookup path. Invalidated when
99
- // any registry in the chain mutates (monotonic version sum).
100
- readonly #defaultLookupByToken = new Map<Token<unknown> | Constructor, DefaultLookupEntry | null>();
101
- #defaultLookupVersion = -1;
102
- // Name-only lookup memo (token → name → entry) with the same chain-version
103
- // invalidation. `null` = shape needs the full selection path.
104
- readonly #namedLookupByToken = new Map<Token<unknown> | Constructor, Map<string, DefaultLookupEntry | null>>();
105
- #namedLookupVersion = -1;
106
- // Compiled transient-class plans (Dagger-style): a pure-static subgraph (class/constant/
107
- // cached-singleton deps only) compiles once into a nested-constructor closure — cycle
108
- // checking happens at compile time, so execution skips all per-resolve bookkeeping.
109
- // `null` = binding is not plannable under the current versions.
70
+ /**
71
+ * The pair a top-level **sync** resolve reuses instead of minting two arrays per call.
72
+ *
73
+ * @remarks Read directly rather than through an accessor returning both: a shallow resolve is one
74
+ * top-level call, so a call and an object literal there are not amortised over anything. Every sync
75
+ * lane pops what it pushes, so `rootStack.length === 0` means no resolve holds the pair; async
76
+ * appends without popping and mints its own. Keeping the pair stable is also what lets a pooled
77
+ * context skip storing pointers it already holds.
78
+ */
79
+ readonly rootPath: Array<string> = [];
80
+ readonly rootStack: Array<ResolutionFrame> = [];
81
+ // The open synchronous factory cascade: its arrays are the ancestor chain, and they are balanced
82
+ // because synchronous code does not interleave.
83
+ readonly #cascadePath: Array<string> = [];
84
+ readonly #cascadeStack: Array<ResolutionFrame> = [];
85
+ #cascadeContext: AsyncCascadeContext | undefined;
86
+ // Compiled plans; `null` marks a binding as unplannable under the current cache versions.
110
87
  readonly #classPlanByBindingId = new Map<BindingIdentifier, (() => unknown) | null>();
111
88
  #classPlanRegistryVersion = -1;
112
89
  #classPlanActivationVersion = -1;
@@ -115,8 +92,10 @@ export class DependencyResolver {
115
92
  readonly #scope: ScopeManager;
116
93
  readonly #lifecycle: LifecycleManager;
117
94
  readonly #metadataReader: MetadataReader;
118
- readonly #container: Container;
119
95
  readonly #parent: DependencyResolver | undefined;
96
+ readonly #lookup: BindingLookupCache<DependencyResolver>;
97
+ readonly #classes: ClassIntrospector;
98
+ readonly #activation: ActivationNeedCache;
120
99
 
121
100
  constructor(
122
101
  registry: BindingRegistry,
@@ -130,8 +109,28 @@ export class DependencyResolver {
130
109
  this.#scope = scope;
131
110
  this.#lifecycle = lifecycle;
132
111
  this.#metadataReader = metadataReader;
133
- this.#container = container;
134
112
  this.#parent = parent;
113
+ this.#lookup = new BindingLookupCache<DependencyResolver>(
114
+ registry,
115
+ this,
116
+ parent === undefined ? undefined : parent.#lookup,
117
+ );
118
+ this.#classes = new ClassIntrospector(metadataReader, container);
119
+ this.#activation = new ActivationNeedCache(lifecycle, this.#classes, registry);
120
+ }
121
+
122
+ /** Structural counts for {@link RESOLUTION_DIAGNOSTICS}; see `resolution/diagnostics.ts`. */
123
+ describeCaches(): Pick<ResolutionDiagnostics, "compiledPlanCount" | "syncContextPoolSize"> {
124
+ let compiledPlanCount = 0;
125
+ for (const plan of this.#classPlanByBindingId.values()) {
126
+ if (plan !== null) {
127
+ compiledPlanCount += 1;
128
+ }
129
+ }
130
+ return {
131
+ compiledPlanCount,
132
+ syncContextPoolSize: this.#syncResolutionContextPool.length,
133
+ };
135
134
  }
136
135
 
137
136
  // ── Binding lookup ─────────────────────────────────────────────────────────
@@ -141,15 +140,13 @@ export class DependencyResolver {
141
140
  options: ResolveOptions | undefined,
142
141
  resolutionPath: Array<string>,
143
142
  resolutionStack: Array<ResolutionFrame>,
144
- ): { binding: Binding; owner: DependencyResolver } | undefined {
143
+ ): DefaultLookupEntry<DependencyResolver> | undefined {
145
144
  if (options === undefined) {
146
145
  const fastDefaultBinding = this.#registry.getFastDefault(token);
147
146
  if (fastDefaultBinding !== undefined) {
148
147
  return { binding: fastDefaultBinding, owner: this };
149
148
  }
150
- }
151
-
152
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
149
+ } else if (isNameOnlyOptions(options)) {
153
150
  const namedBinding = this.#registry.getSimpleNamed(token, options.name);
154
151
  if (
155
152
  namedBinding !== undefined &&
@@ -157,37 +154,33 @@ export class DependencyResolver {
157
154
  ) {
158
155
  return { binding: namedBinding, owner: this };
159
156
  }
160
- }
161
-
162
- if (
163
- options !== undefined &&
164
- options.name === undefined &&
165
- options.tag === undefined &&
166
- (options.tags?.length ?? 0) === 1
167
- ) {
168
- const [tagKey, tagValue] = options.tags![0]!;
169
- const tagged = this.#registry.getSimpleTagged(token, tagKey, tagValue);
170
- if (tagged !== undefined) {
171
- return { binding: tagged, owner: this };
157
+ } else {
158
+ const singleTag = singleTagOnlyOf(options);
159
+ if (singleTag !== undefined) {
160
+ const tagged = this.#registry.getSimpleTagged(token, singleTag[0], singleTag[1]);
161
+ if (tagged !== undefined && matchesIndexedTagValue(tagged, singleTag[1])) {
162
+ return { binding: tagged, owner: this };
163
+ }
172
164
  }
173
165
  }
174
166
 
175
167
  const bindings = this.#registry.getAll(token);
176
168
  if (bindings.length > 0) {
177
- if (bindings.length === 1) {
178
- const onlyBinding = bindings[0]!;
179
- const isDefaultSlot = onlyBinding.slot.name === undefined && onlyBinding.slot.tags.length === 0;
180
- if (options === undefined && isDefaultSlot && onlyBinding.predicate === undefined) {
181
- return { binding: onlyBinding, owner: this };
182
- }
183
- if (this.#matchesBindingFast(onlyBinding, options, resolutionPath, resolutionStack)) {
184
- return { binding: onlyBinding, owner: this };
185
- }
186
- }
187
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
188
- const binding = selectBinding(bindings, options, ctx, this.#getTokenName(token));
189
- if (binding !== undefined) {
190
- return { binding, owner: this };
169
+ // A lone candidate is its own selection: matching it is the whole decision, with no
170
+ // specificity to weigh and no ambiguity to report.
171
+ const selected =
172
+ bindings.length === 1
173
+ ? this.#matchesBindingFast(bindings[0]!, options, resolutionPath, resolutionStack)
174
+ ? bindings[0]
175
+ : undefined
176
+ : selectBinding(
177
+ bindings,
178
+ options,
179
+ this.#makeConstraintContext(resolutionPath, resolutionStack, options),
180
+ tokenName(token),
181
+ );
182
+ if (selected !== undefined) {
183
+ return { binding: selected, owner: this };
191
184
  }
192
185
  }
193
186
  if (this.#parent !== undefined) {
@@ -196,13 +189,55 @@ export class DependencyResolver {
196
189
  return undefined;
197
190
  }
198
191
 
192
+ /**
193
+ * The binding a token resolves to with alias hops followed, or a diagnostic throw.
194
+ *
195
+ * @remarks Alias hops are followed iteratively with exact cycle detection — a revisited alias
196
+ * token raises {@link CircularDependencyError} instead of overflowing the call stack.
197
+ */
198
+ #requireBinding(
199
+ token: Token<unknown> | Constructor,
200
+ options: ResolveOptions | undefined,
201
+ resolutionPath: Array<string>,
202
+ resolutionStack: Array<ResolutionFrame>,
203
+ ): DefaultLookupEntry<DependencyResolver> {
204
+ let currentToken = token;
205
+ let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
206
+ let found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
207
+
208
+ while (found !== undefined && found.binding.kind === "alias") {
209
+ const target = found.binding.target;
210
+ visitedAliasTokens ??= new Set([currentToken]);
211
+ if (visitedAliasTokens.has(target)) {
212
+ throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
213
+ }
214
+ visitedAliasTokens.add(target);
215
+ currentToken = target;
216
+ found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
217
+ }
218
+
219
+ if (found === undefined) {
220
+ // Thrown here rather than from a helper: the error captures this stack, and an error path is
221
+ // dominated by that capture. Bindings under the token mean the request matched none of them.
222
+ if (this.#registry.getAll(currentToken).length > 0) {
223
+ throw new NoMatchingBindingError(
224
+ tokenName(currentToken),
225
+ options ?? {},
226
+ this.#registry.availableSlotStrings(currentToken),
227
+ );
228
+ }
229
+ throw new TokenNotBoundError(tokenName(currentToken));
230
+ }
231
+ return found;
232
+ }
233
+
199
234
  /**
200
235
  * Binding lookup aligned with `resolve` — used by `Container.validate` without instantiating.
201
236
  */
202
237
  peekBindingForValidate(
203
238
  token: Token<unknown> | Constructor,
204
239
  options: ResolveOptions | undefined,
205
- ): { binding: Binding; owner: DependencyResolver } | undefined {
240
+ ): DefaultLookupEntry<DependencyResolver> | undefined {
206
241
  return this.#findBinding(token, options, [], []);
207
242
  }
208
243
 
@@ -212,16 +247,8 @@ export class DependencyResolver {
212
247
  peekCandidateBindingsForValidate(
213
248
  token: Token<unknown> | Constructor,
214
249
  options: ResolveOptions | undefined,
215
- ): Array<Binding> {
216
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
217
- return this.#getSimpleNamedBindingsFromChain(token, options.name);
218
- }
219
- const allBindings = this.#getAllBindingsFromChain(token);
220
- if (allBindings.length === 0) {
221
- return [];
222
- }
223
- const ctx = this.#makeConstraintContext([], [], options);
224
- return selectAllBindings(allBindings, options, ctx);
250
+ ): ReadonlyArray<Binding> {
251
+ return this.#candidateBindings(token, options, [], []);
225
252
  }
226
253
 
227
254
  // ── Sync resolve ───────────────────────────────────────────────────────────
@@ -235,85 +262,77 @@ export class DependencyResolver {
235
262
  // (parent-chain walk + alias folding) only on miss or alias.
236
263
  const fastBinding = this.#registry.getFastDefault(token);
237
264
  if (fastBinding !== undefined && fastBinding.kind !== "alias") {
238
- return this.#resolveDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack);
265
+ return this.#resolveDefaultEntry(fastBinding, this, resolutionPath, resolutionStack) as Value;
239
266
  }
240
- const entry = this.#lookupDefaultEntry(token);
267
+ const entry = this.#lookup.defaultEntry(token);
241
268
  if (entry === null) {
242
269
  return this.resolve(token, undefined, resolutionPath, resolutionStack);
243
270
  }
244
- return this.#resolveDefaultEntry<Value>(entry.binding, entry.owner, resolutionPath, resolutionStack);
271
+ return this.#resolveDefaultEntry(entry.binding, entry.owner, resolutionPath, resolutionStack) as Value;
245
272
  }
246
273
 
247
- #resolveDefaultEntry<const Value>(
274
+ #resolveDefaultEntry(
248
275
  binding: Binding,
249
276
  owner: DependencyResolver,
250
277
  resolutionPath: Array<string>,
251
278
  resolutionStack: Array<ResolutionFrame>,
252
- ): Value {
253
- if (
254
- binding.kind === "constant" &&
255
- binding.onActivation === undefined &&
256
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
257
- ) {
258
- return binding.value as Value;
259
- }
260
- const scope = (binding as BindingWithScope).scope ?? "transient";
279
+ ): unknown {
280
+ const scope = binding.scope;
261
281
  if (scope === "transient") {
262
282
  if (binding.kind === "dynamic") {
263
283
  const containerHooks =
264
284
  this.#lifecycle.activationVersion === 0 ? undefined : this.#lifecycle.activationHandlersFor(binding.token);
265
285
  if (binding.onActivation === undefined && (containerHooks === undefined || containerHooks.length === 0)) {
266
- return this.#resolveTransientDynamicSyncFromContext(
267
- binding as Binding<Value> & { kind: "dynamic" },
268
- resolutionPath,
269
- resolutionStack,
270
- );
286
+ return this.#resolveTransientDynamicSyncFromContext(binding, resolutionPath, resolutionStack);
271
287
  }
272
- return this.#resolveTransientDynamicActivatedSync(
273
- binding as Binding<Value> & { kind: "dynamic" },
274
- containerHooks,
275
- resolutionPath,
276
- resolutionStack,
277
- );
288
+ return this.#resolveTransientDynamicActivatedSync(binding, containerHooks, resolutionPath, resolutionStack);
278
289
  }
279
290
  // Compiled plans only run at the top level — inner levels keep the runtime cycle guard.
280
291
  if ((binding.kind === "class" || binding.kind === "resolved") && resolutionPath.length === 0) {
281
292
  const plan = this.#getInstantiationPlan(binding);
282
293
  if (plan !== null) {
283
- return plan() as Value;
294
+ return plan();
284
295
  }
285
296
  }
286
297
  } else if (scope === "singleton") {
287
- const cachedSingleton = owner.#scope.peekSingleton(binding.id);
288
- if (cachedSingleton !== SINGLETON_MISS) {
289
- return cachedSingleton as Value;
298
+ // A constant is a singleton that is already its own instance.
299
+ if (this.#isPlainConstant(binding)) {
300
+ return binding.value;
301
+ }
302
+ const cachedSingleton = binding.instance;
303
+ if (cachedSingleton !== NO_INSTANCE) {
304
+ return cachedSingleton;
290
305
  }
291
306
  if (owner !== this) {
292
- return owner.#resolveBinding(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
307
+ return owner.#resolveBinding(binding, undefined, resolutionPath, resolutionStack);
293
308
  }
294
309
  } else {
295
- if (!this.#scope.isChild) {
296
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
297
- }
298
- if (this.#scope.hasScoped(binding.id)) {
299
- return this.#scope.getScoped<Value>(binding.id);
310
+ const cachedScoped = this.#readScoped(binding);
311
+ if (cachedScoped !== SCOPED_MISS) {
312
+ return cachedScoped;
300
313
  }
301
314
  }
302
- return this.#resolveBinding(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
315
+ return this.#resolveBinding(binding, undefined, resolutionPath, resolutionStack);
303
316
  }
304
317
 
305
318
  // Lean lane for an activated transient dynamic binding: same observable behavior as the
306
319
  // generic #resolveBinding path (guard, frame, ctx, per-binding then container hooks) with
307
320
  // the kind/activation dispatch resolved statically.
308
- #resolveTransientDynamicActivatedSync<const Value>(
309
- binding: Binding<Value> & { kind: "dynamic" },
321
+ #resolveTransientDynamicActivatedSync(
322
+ binding: DynamicBinding<unknown>,
310
323
  containerHooks: ReadonlyArray<ActivationHandler<unknown>> | undefined,
311
324
  resolutionPath: Array<string>,
312
325
  resolutionStack: Array<ResolutionFrame>,
313
- ): Value {
326
+ ): unknown {
327
+ // Same O(1) cycle guard as the unhooked lane: this is still one sync call stack, so the flag
328
+ // *is* exact path membership — see ARCHITECTURE.md.
314
329
  const frame = this.#getResolutionFrame(binding);
315
330
  const tokenDisplayName = frame.tokenName;
316
- const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName, false);
331
+ if (binding.inFlight) {
332
+ throw new CircularDependencyError([...resolutionPath, tokenDisplayName]);
333
+ }
334
+ binding.inFlight = true;
335
+ resolutionPath.push(tokenDisplayName);
317
336
  resolutionStack.push(frame);
318
337
  try {
319
338
  const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, undefined);
@@ -335,100 +354,19 @@ export class DependencyResolver {
335
354
  if (activationResult instanceof Promise) {
336
355
  throw new AsyncActivationError(tokenDisplayName, "onActivation");
337
356
  }
338
- activated = activationResult as Value;
357
+ activated = activationResult;
339
358
  }
340
359
  }
341
360
  return activated;
342
361
  } finally {
343
362
  resolutionStack.pop();
344
363
  resolutionPath.pop();
345
- resolutionSet?.delete(tokenDisplayName);
346
- }
347
- }
348
-
349
- #chainRegistryVersion(): number {
350
- let version = this.#registry.version;
351
- for (let resolver = this.#parent; resolver !== undefined; resolver = resolver.#parent) {
352
- version += resolver.#registry.version;
353
- }
354
- return version;
355
- }
356
-
357
- #lookupDefaultEntry(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
358
- const version = this.#chainRegistryVersion();
359
- if (version !== this.#defaultLookupVersion) {
360
- this.#defaultLookupByToken.clear();
361
- this.#defaultLookupVersion = version;
362
- }
363
- let entry = this.#defaultLookupByToken.get(token);
364
- if (entry === undefined) {
365
- entry = this.#computeDefaultEntry(token);
366
- this.#defaultLookupByToken.set(token, entry);
367
- }
368
- return entry;
369
- }
370
-
371
- #computeDefaultEntry(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
372
- let current = token;
373
- for (let hop = 0; hop < ALIAS_HOP_LIMIT; hop += 1) {
374
- const entry = this.#findDefaultEntryInChain(current);
375
- if (entry === null) {
376
- return null;
377
- }
378
- if (entry.binding.kind === "alias") {
379
- current = entry.binding.target;
380
- continue;
381
- }
382
- return entry;
383
- }
384
- return null;
385
- }
386
-
387
- #lookupNamedEntry(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry | null {
388
- const version = this.#chainRegistryVersion();
389
- if (version !== this.#namedLookupVersion) {
390
- this.#namedLookupByToken.clear();
391
- this.#namedLookupVersion = version;
392
- }
393
- // ✓ TS6.0: Map.getOrInsert (ES2025)
394
- const entriesByName = this.#namedLookupByToken.getOrInsert(token, new Map<string, DefaultLookupEntry | null>());
395
- let entry = entriesByName.get(name);
396
- if (entry === undefined) {
397
- entry = this.#findNamedEntryInChain(token, name);
398
- entriesByName.set(name, entry);
399
- }
400
- return entry;
401
- }
402
-
403
- #findNamedEntryInChain(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry | null {
404
- const named = this.#registry.getSimpleNamed(token, name);
405
- if (named !== undefined) {
406
- // Predicates need a live context; aliases carry options through the full path.
407
- if (named.predicate !== undefined || named.kind === "alias") {
408
- return null;
409
- }
410
- return { binding: named, owner: this };
411
- }
412
- if (this.#registry.has(token)) {
413
- return null;
414
- }
415
- return this.#parent === undefined ? null : this.#parent.#findNamedEntryInChain(token, name);
416
- }
417
-
418
- #findDefaultEntryInChain(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
419
- const fast = this.#registry.getFastDefault(token);
420
- if (fast !== undefined) {
421
- return { binding: fast, owner: this };
422
- }
423
- // A level with non-fast bindings (multi-slot / predicate) needs full selection — bail.
424
- if (this.#registry.has(token)) {
425
- return null;
364
+ binding.inFlight = false;
426
365
  }
427
- return this.#parent === undefined ? null : this.#parent.#findDefaultEntryInChain(token);
428
366
  }
429
367
 
430
368
  #getInstantiationPlan(binding: Binding & { kind: "class" | "resolved" }): (() => unknown) | null {
431
- const registryVersion = this.#chainRegistryVersion();
369
+ const registryVersion = this.#lookup.chainVersion();
432
370
  const activationVersion = this.#lifecycle.activationVersion;
433
371
  if (registryVersion !== this.#classPlanRegistryVersion || activationVersion !== this.#classPlanActivationVersion) {
434
372
  this.#classPlanByBindingId.clear();
@@ -448,25 +386,31 @@ export class DependencyResolver {
448
386
  return compiled;
449
387
  }
450
388
 
451
- // Compiler behind #getClassPlan — cold path, so the host indirection costs nothing hot.
389
+ // Compiler behind #getInstantiationPlan — cold path, so the host indirection costs nothing hot.
452
390
  readonly #planCompiler = new InstantiationPlanCompiler({
453
391
  hasActivationHandlers: (token) => this.#lifecycle.hasActivationHandlers(token),
454
- knownPostConstruct: (target) => this.#classHasPostConstruct.get(target),
455
- needsActiveContainer: (target) => {
456
- let needsActiveContainer = this.#classNeedsActiveContainer.get(target);
457
- if (needsActiveContainer === undefined) {
458
- const accessorMetadata = this.#metadataReader.getAccessorMetadata?.(target);
459
- needsActiveContainer = (accessorMetadata?.length ?? 0) > 0;
460
- this.#classNeedsActiveContainer.set(target, needsActiveContainer);
461
- }
462
- return needsActiveContainer;
463
- },
464
- getConstructorMetadata: (target) => this.#getConstructorMetadata(target),
392
+ knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
393
+ needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
394
+ getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
465
395
  lookupDependencyEntry: (token) => {
466
- const entry = this.#lookupDefaultEntry(token);
467
- return entry === null ? null : { binding: entry.binding, ownerScope: entry.owner.#scope };
396
+ const entry = this.#lookup.defaultEntry(token);
397
+ return entry === null ? null : { binding: entry.binding };
398
+ },
399
+ getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
400
+ // Dispatches exactly as #resolveDep does, so an escaped dep is indistinguishable
401
+ // from the same dep on a fully interpreted resolve.
402
+ resolveEscaped: (token, options, arity, resolutionPath, resolutionStack) => {
403
+ if (arity === "all") {
404
+ return this.resolveAll(token, options, resolutionPath, resolutionStack);
405
+ }
406
+ if (arity === "optional") {
407
+ return this.resolveOptional(token, options, resolutionPath, resolutionStack);
408
+ }
409
+ if (options === undefined) {
410
+ return this.resolveFromContext(token, resolutionPath, resolutionStack);
411
+ }
412
+ return this.resolve(token, options, resolutionPath, resolutionStack);
468
413
  },
469
- resolveFallback: (token) => this.resolve(token, undefined, [], []),
470
414
  });
471
415
 
472
416
  resolve<const Value>(
@@ -477,142 +421,74 @@ export class DependencyResolver {
477
421
  ): Value {
478
422
  // Name-only fast lane: memoized lookup, dispatching just the shapes whose
479
423
  // semantics involve no resolution context (constants, cached singletons).
480
- if (
481
- options !== undefined &&
482
- options.name !== undefined &&
483
- options.tag === undefined &&
484
- (options.tags === undefined || options.tags.length === 0)
485
- ) {
486
- const namedEntry = this.#lookupNamedEntry(token, options.name);
424
+ if (options !== undefined && isNameOnlyOptions(options)) {
425
+ const namedEntry = this.#lookup.namedEntry(token, options.name);
487
426
  if (namedEntry !== null) {
488
427
  const namedBinding = namedEntry.binding;
489
- if (
490
- namedBinding.kind === "constant" &&
491
- namedBinding.onActivation === undefined &&
492
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(namedBinding.token))
493
- ) {
428
+ if (this.#isPlainConstant(namedBinding)) {
494
429
  return namedBinding.value as Value;
495
430
  }
496
- const namedScope = (namedBinding as BindingWithScope).scope ?? "transient";
497
- if (namedScope === "singleton") {
498
- const cachedSingleton = namedEntry.owner.#scope.peekSingleton(namedBinding.id);
499
- if (cachedSingleton !== SINGLETON_MISS) {
500
- return cachedSingleton as Value;
501
- }
431
+ if (namedBinding.scope === "singleton" && namedBinding.instance !== NO_INSTANCE) {
432
+ return namedBinding.instance as Value;
502
433
  }
503
434
  // Everything else keeps the full path (context, activation, guards).
504
435
  }
505
436
  }
506
437
 
507
- let currentToken: Token<unknown> | Constructor = token;
508
- let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
509
- let found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
438
+ const { binding, owner } = this.#requireBinding(token, options, resolutionPath, resolutionStack);
510
439
 
511
- // Follow aliases iteratively with exact cycle detection — a revisited alias
512
- // token throws CircularDependencyError instead of overflowing the call stack.
513
- while (found !== undefined && found.binding.kind === "alias") {
514
- const target = found.binding.target;
515
- visitedAliasTokens ??= new Set([currentToken]);
516
- if (visitedAliasTokens.has(target)) {
517
- throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
518
- }
519
- visitedAliasTokens.add(target);
520
- currentToken = target;
521
- found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
440
+ // A singleton owned by a parent resolver is resolved there, so the parent caches it.
441
+ if (binding.scope === "singleton" && owner !== this) {
442
+ return owner.#resolveBinding(binding, options, resolutionPath, resolutionStack) as Value;
522
443
  }
523
-
524
- if (found === undefined) {
525
- const ownBindings = this.#registry.getAll(currentToken);
526
- if (ownBindings.length > 0) {
527
- throw new NoMatchingBindingError(
528
- this.#getTokenName(currentToken),
529
- options ?? {},
530
- this.#getAvailableSlots(currentToken),
531
- );
532
- }
533
- throw new TokenNotBoundError(this.#getTokenName(currentToken));
534
- }
535
-
536
- const { binding, owner } = found;
537
-
538
- const scope = (binding as BindingWithScope).scope ?? "transient";
539
-
540
- // Singleton from a parent resolver: delegate so the parent caches it correctly
541
- if (scope === "singleton" && owner !== this) {
542
- return owner.#resolveBinding(binding as Binding<Value>, options, resolutionPath, resolutionStack);
543
- }
544
-
545
- // Scoped/transient (or own singleton): resolve with this resolver's container/scope
546
- return this.#resolveBinding(binding as Binding<Value>, options, resolutionPath, resolutionStack);
444
+ return this.#resolveBinding(binding, options, resolutionPath, resolutionStack) as Value;
547
445
  }
548
446
 
549
- #resolveBinding<const Value>(
550
- binding: Binding<Value>,
447
+ #resolveBinding(
448
+ binding: Binding,
551
449
  options: ResolveOptions | undefined,
552
450
  resolutionPath: Array<string>,
553
451
  resolutionStack: Array<ResolutionFrame>,
554
- ): Value {
555
- if (
556
- binding.kind === "constant" &&
557
- binding.onActivation === undefined &&
558
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
559
- ) {
452
+ ): unknown {
453
+ if (this.#isPlainConstant(binding)) {
560
454
  return binding.value;
561
455
  }
562
456
 
563
- const scope = (binding as BindingWithScope).scope ?? "transient";
564
-
565
- // Singleton cache check
457
+ const scope = binding.scope;
566
458
  if (scope === "singleton") {
567
- if (this.#scope.hasSingleton(binding.id)) {
568
- return this.#scope.getSingleton<Value>(binding.id);
569
- }
570
- }
571
-
572
- // Scoped cache check
573
- if (scope === "scoped") {
574
- if (!this.#scope.isChild) {
575
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
459
+ if (binding.instance !== NO_INSTANCE) {
460
+ return binding.instance;
576
461
  }
577
- if (this.#scope.hasScoped(binding.id)) {
578
- return this.#scope.getScoped<Value>(binding.id);
462
+ } else if (scope === "scoped") {
463
+ const cachedScoped = this.#readScoped(binding);
464
+ if (cachedScoped !== SCOPED_MISS) {
465
+ return cachedScoped;
579
466
  }
580
467
  }
581
468
 
582
469
  const frame = this.#getResolutionFrame(binding);
583
470
  const tokenDisplayName = frame.tokenName;
584
- const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName, false);
471
+ const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName);
585
472
  resolutionStack.push(frame);
586
- const needsActivation = this.#needsActivation(binding);
587
- if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
588
- const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options);
589
- try {
473
+ try {
474
+ const needsActivation = this.#activation.needsActivation(binding);
475
+ if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
476
+ const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options);
590
477
  const dynamicResult = binding.factory(resolutionCtx);
591
478
  if (dynamicResult instanceof Promise) {
592
- throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
479
+ throw new AsyncResolutionError(tokenDisplayName, tokenDisplayName);
593
480
  }
594
- resolutionStack.pop();
595
- resolutionPath.pop();
596
- resolutionSet?.delete(tokenDisplayName);
597
481
  return dynamicResult;
598
- } catch (error) {
599
- resolutionStack.pop();
600
- resolutionPath.pop();
601
- resolutionSet?.delete(tokenDisplayName);
602
- throw error;
603
482
  }
604
- }
605
483
 
606
- try {
607
- const needsResolutionContext = needsActivation || this.#requiresResolutionContext(binding);
608
- const resolutionCtx = needsResolutionContext
609
- ? this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options)
610
- : undefined;
484
+ const resolutionCtx =
485
+ needsActivation || requiresResolutionContext(binding)
486
+ ? this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options)
487
+ : undefined;
611
488
 
612
489
  const instance = this.#instantiateSync(binding, resolutionCtx, resolutionPath, resolutionStack);
613
490
 
614
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
615
- const activated = shouldActivate
491
+ const activated = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation)
616
492
  ? this.#lifecycle.runActivationSync(
617
493
  resolutionCtx as DefaultResolutionContext,
618
494
  binding,
@@ -621,9 +497,8 @@ export class DependencyResolver {
621
497
  )
622
498
  : instance;
623
499
 
624
- // Cache by scope
625
500
  if (scope === "singleton") {
626
- this.#scope.setSingleton(binding.id, activated);
501
+ this.#scope.setSingleton(binding, activated);
627
502
  } else if (scope === "scoped") {
628
503
  this.#scope.setScoped(binding.id, activated);
629
504
  }
@@ -636,12 +511,12 @@ export class DependencyResolver {
636
511
  }
637
512
  }
638
513
 
639
- #instantiateSync<const Value>(
640
- binding: Binding<Value>,
514
+ #instantiateSync(
515
+ binding: Binding,
641
516
  ctx: DefaultResolutionContext | undefined,
642
517
  resolutionPath: Array<string>,
643
518
  resolutionStack: Array<ResolutionFrame>,
644
- ): Value {
519
+ ): unknown {
645
520
  switch (binding.kind) {
646
521
  case "constant":
647
522
  return binding.value;
@@ -661,13 +536,12 @@ export class DependencyResolver {
661
536
  throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
662
537
 
663
538
  case "class": {
664
- const deps = this.#resolveClassDeps(binding.target, resolutionPath, resolutionStack);
665
- const instance = this.#instantiateClass(binding.target, deps);
666
- return instance as Value;
539
+ const deps = this.#resolveDeps(this.#constructorParams(binding.target), resolutionPath, resolutionStack);
540
+ return this.#classes.instantiate(binding.target, deps);
667
541
  }
668
542
 
669
543
  case "resolved": {
670
- const deps = this.#resolveDescriptorDeps(binding.deps, resolutionPath, resolutionStack);
544
+ const deps = this.#resolveDeps(binding.deps, resolutionPath, resolutionStack);
671
545
  const factoryResult = binding.factory(...deps);
672
546
  if (factoryResult instanceof Promise) {
673
547
  throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
@@ -683,90 +557,58 @@ export class DependencyResolver {
683
557
  }
684
558
  }
685
559
 
686
- #resolveClassDeps(
687
- target: Constructor,
688
- resolutionPath: Array<string>,
689
- resolutionStack: Array<ResolutionFrame>,
690
- ): Array<unknown> {
691
- const meta = this.#getConstructorMetadata(target);
692
- if (meta === undefined) {
693
- if (target.length === 0) {
694
- return [];
695
- }
696
- throw new MissingMetadataError(target.name);
560
+ /**
561
+ * The parameters a class binding injects.
562
+ *
563
+ * @remarks A class the metadata reader knows nothing about is constructible only if it declares
564
+ * no parameters; anything else is a missing `@injectable()`.
565
+ */
566
+ #constructorParams(target: Constructor): ReadonlyArray<ParamMetadata> {
567
+ const meta = this.#classes.constructorMetadata(target);
568
+ if (meta !== undefined) {
569
+ return meta.params;
697
570
  }
698
- if (meta.params.length === 0) {
699
- return [];
571
+ if (target.length === 0) {
572
+ return EMPTY_PARAM_LIST;
700
573
  }
701
- if (meta.params.length === 1) {
702
- const param = meta.params[0]!;
703
- const paramOptions = injectionSlotToResolveOptions(param);
704
- if (param.multi) {
705
- return [this.resolveAll(param.token, paramOptions, resolutionPath, resolutionStack)];
706
- }
707
- if (param.optional) {
708
- return [this.resolveOptional(param.token, paramOptions, resolutionPath, resolutionStack)];
709
- }
710
- if (paramOptions === undefined) {
711
- return [this.resolveFromContext(param.token, resolutionPath, resolutionStack)];
712
- }
713
- return [this.resolve(param.token, paramOptions, resolutionPath, resolutionStack)];
714
- }
715
- const deps = new Array<unknown>(meta.params.length);
716
- for (let index = 0; index < meta.params.length; index += 1) {
717
- const param = meta.params[index]!;
718
- const paramOptions = injectionSlotToResolveOptions(param);
719
- if (param.multi) {
720
- deps[index] = this.resolveAll(param.token, paramOptions, resolutionPath, resolutionStack);
721
- continue;
722
- }
723
- if (param.optional) {
724
- deps[index] = this.resolveOptional(param.token, paramOptions, resolutionPath, resolutionStack);
725
- continue;
726
- }
727
- deps[index] =
728
- paramOptions === undefined
729
- ? this.resolveFromContext(param.token, resolutionPath, resolutionStack)
730
- : this.resolve(param.token, paramOptions, resolutionPath, resolutionStack);
731
- }
732
- return deps;
574
+ throw new MissingMetadataError(target.name);
733
575
  }
734
576
 
735
- #resolveDescriptorDeps(
736
- deps: ReadonlyArray<InjectionDescriptor>,
577
+ // One dispatch table for both dependency sources — constructor params and `toResolved`
578
+ // descriptors declare the same four things.
579
+ #resolveDeps(
580
+ deps: ReadonlyArray<DependencySlot>,
737
581
  resolutionPath: Array<string>,
738
582
  resolutionStack: Array<ResolutionFrame>,
739
583
  ): Array<unknown> {
740
- const resolved = new Array<unknown>(deps.length);
741
- for (let index = 0; index < deps.length; index += 1) {
742
- const dep = deps[index]!;
743
- const depOptions = injectionSlotToResolveOptions(dep);
744
- if (dep.multi) {
745
- resolved[index] = this.resolveAll(
746
- dep.token as Token<unknown> | Constructor,
747
- depOptions,
748
- resolutionPath,
749
- resolutionStack,
750
- );
751
- continue;
752
- }
753
- if (dep.optional) {
754
- resolved[index] = this.resolveOptional(
755
- dep.token as Token<unknown> | Constructor,
756
- depOptions,
757
- resolutionPath,
758
- resolutionStack,
759
- );
760
- continue;
761
- }
762
- resolved[index] =
763
- depOptions === undefined
764
- ? this.resolveFromContext(dep.token as Token<unknown> | Constructor, resolutionPath, resolutionStack)
765
- : this.resolve(dep.token as Token<unknown> | Constructor, depOptions, resolutionPath, resolutionStack);
584
+ const count = deps.length;
585
+ if (count === 0) {
586
+ return [];
587
+ }
588
+ if (count === 1) {
589
+ return [this.#resolveDep(deps[0]!, resolutionPath, resolutionStack)];
590
+ }
591
+ const resolved = new Array<unknown>(count);
592
+ for (let index = 0; index < count; index += 1) {
593
+ resolved[index] = this.#resolveDep(deps[index]!, resolutionPath, resolutionStack);
766
594
  }
767
595
  return resolved;
768
596
  }
769
597
 
598
+ #resolveDep(dep: DependencySlot, resolutionPath: Array<string>, resolutionStack: Array<ResolutionFrame>): unknown {
599
+ const options = injectionSlotToResolveOptions(dep);
600
+ if (dep.multi) {
601
+ return this.resolveAll(dep.token, options, resolutionPath, resolutionStack);
602
+ }
603
+ if (dep.optional) {
604
+ return this.resolveOptional(dep.token, options, resolutionPath, resolutionStack);
605
+ }
606
+ if (options === undefined) {
607
+ return this.resolveFromContext(dep.token, resolutionPath, resolutionStack);
608
+ }
609
+ return this.resolve(dep.token, options, resolutionPath, resolutionStack);
610
+ }
611
+
770
612
  resolveOptional<const Value>(
771
613
  token: Token<Value> | Constructor<Value>,
772
614
  options: ResolveOptions | undefined,
@@ -785,49 +627,53 @@ export class DependencyResolver {
785
627
  resolutionPath: Array<string>,
786
628
  resolutionStack: Array<ResolutionFrame>,
787
629
  ): Array<Value> {
788
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
789
- const namedCandidates = this.#getSimpleNamedBindingsFromChain(token, options.name);
790
- if (namedCandidates.length === 0) {
791
- return [];
792
- }
793
- const resolved = new Array<Value>(namedCandidates.length);
794
- for (let index = 0; index < namedCandidates.length; index += 1) {
795
- resolved[index] = this.#resolveCandidateSync(
796
- namedCandidates[index] as Binding<Value>,
797
- options,
798
- resolutionPath,
799
- resolutionStack,
800
- );
801
- }
802
- return resolved;
803
- }
804
-
805
- const allBindings = this.#getAllBindingsFromChain(token);
806
- if (allBindings.length === 0) {
807
- return [];
808
- }
809
-
810
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
811
- const candidates = selectAllBindings(allBindings, options, ctx);
812
-
630
+ const candidates = this.#candidateBindings(token, options, resolutionPath, resolutionStack);
813
631
  const resolved = new Array<Value>(candidates.length);
814
632
  for (let index = 0; index < candidates.length; index += 1) {
815
633
  resolved[index] = this.#resolveCandidateSync(
816
- candidates[index] as Binding<Value>,
634
+ candidates[index]!,
817
635
  options,
818
636
  resolutionPath,
819
637
  resolutionStack,
820
- );
638
+ ) as Value;
821
639
  }
822
640
  return resolved;
823
641
  }
824
642
 
643
+ /** Every binding in the chain a `resolveAll` request matches, in chain order. */
644
+ #candidateBindings(
645
+ token: Token<unknown> | Constructor,
646
+ options: ResolveOptions | undefined,
647
+ resolutionPath: Array<string>,
648
+ resolutionStack: Array<ResolutionFrame>,
649
+ ): ReadonlyArray<Binding> {
650
+ if (options !== undefined && isNameOnlyOptions(options)) {
651
+ // The name index has matched the slot already, but a hit may still carry a predicate —
652
+ // and that is the selection path's job to evaluate.
653
+ const named = this.#namedBindingsFromChain(token, options.name);
654
+ if (!anyPredicate(named)) {
655
+ return named;
656
+ }
657
+ return selectAllBindings(named, options, this.#makeConstraintContext(resolutionPath, resolutionStack, options));
658
+ }
659
+ const allBindings = this.#allBindingsFromChain(token);
660
+ if (allBindings.length === 0) {
661
+ return allBindings;
662
+ }
663
+ return selectAllBindings(
664
+ allBindings,
665
+ options,
666
+ this.#makeConstraintContext(resolutionPath, resolutionStack, options),
667
+ );
668
+ }
669
+
825
670
  // ── Async resolve ──────────────────────────────────────────────────────────
826
671
 
827
672
  resolveAsyncFromContext<const Value>(
828
673
  token: Token<Value> | Constructor<Value>,
829
674
  resolutionPath: Array<string>,
830
675
  resolutionStack: Array<ResolutionFrame>,
676
+ branchDepth: BranchDepth,
831
677
  ): Promise<Value> {
832
678
  // Hot lane: own-registry fast default (async chains resolve sibling dynamic bindings).
833
679
  // Fall back to the chain-versioned memo only on miss or alias.
@@ -837,67 +683,67 @@ export class DependencyResolver {
837
683
  if (
838
684
  (fastBinding.kind === "dynamic-async" || fastBinding.kind === "dynamic") &&
839
685
  fastBinding.scope === "transient" &&
840
- fastBinding.onActivation === undefined &&
841
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(fastBinding.token))
686
+ !this.#hasAnyActivation(fastBinding)
842
687
  ) {
843
688
  return this.#resolveTransientDynamicAsyncFromContext(
844
- fastBinding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
689
+ fastBinding,
845
690
  resolutionPath,
846
691
  resolutionStack,
847
- );
692
+ branchDepth,
693
+ ) as Promise<Value>;
848
694
  }
849
- return this.#resolveAsyncDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack);
695
+ return this.#resolveAsyncDefaultEntry(
696
+ fastBinding,
697
+ this,
698
+ resolutionPath,
699
+ resolutionStack,
700
+ branchDepth,
701
+ ) as Promise<Value>;
850
702
  }
851
- const entry = this.#lookupDefaultEntry(token);
703
+ const entry = this.#lookup.defaultEntry(token);
852
704
  if (entry === null) {
853
- return this.resolveAsync(token, undefined, resolutionPath, resolutionStack);
705
+ return this.resolveAsync(token, undefined, resolutionPath, resolutionStack, branchDepth);
854
706
  }
855
- return this.#resolveAsyncDefaultEntry<Value>(entry.binding, entry.owner, resolutionPath, resolutionStack);
707
+ return this.#resolveAsyncDefaultEntry(
708
+ entry.binding,
709
+ entry.owner,
710
+ resolutionPath,
711
+ resolutionStack,
712
+ branchDepth,
713
+ ) as Promise<Value>;
856
714
  }
857
715
 
858
- #resolveAsyncDefaultEntry<const Value>(
716
+ #resolveAsyncDefaultEntry(
859
717
  binding: Binding,
860
718
  owner: DependencyResolver,
861
719
  resolutionPath: Array<string>,
862
720
  resolutionStack: Array<ResolutionFrame>,
863
- ): Promise<Value> {
864
- if (
865
- binding.kind === "constant" &&
866
- binding.onActivation === undefined &&
867
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
868
- ) {
869
- return Promise.resolve(binding.value as Value);
721
+ branchDepth: BranchDepth,
722
+ ): Promise<unknown> {
723
+ if (this.#isPlainConstant(binding)) {
724
+ return Promise.resolve(binding.value);
870
725
  }
871
- const scope = (binding as BindingWithScope).scope ?? "transient";
726
+ const scope = binding.scope;
872
727
  if (scope === "transient") {
873
- if (
874
- (binding.kind === "dynamic" || binding.kind === "dynamic-async") &&
875
- binding.onActivation === undefined &&
876
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
877
- ) {
878
- return this.#resolveTransientDynamicAsyncFromContext(
879
- binding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
880
- resolutionPath,
881
- resolutionStack,
882
- );
728
+ if ((binding.kind === "dynamic" || binding.kind === "dynamic-async") && !this.#hasAnyActivation(binding)) {
729
+ return this.#resolveTransientDynamicAsyncFromContext(binding, resolutionPath, resolutionStack, branchDepth);
883
730
  }
884
731
  } else if (scope === "singleton") {
885
- const cachedSingleton = owner.#scope.peekSingleton(binding.id);
886
- if (cachedSingleton !== SINGLETON_MISS) {
887
- return Promise.resolve(cachedSingleton as Value);
732
+ if (binding.instance !== NO_INSTANCE) {
733
+ return Promise.resolve(binding.instance);
888
734
  }
889
735
  if (owner !== this) {
890
- return owner.#resolveBindingAsync(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
891
- }
892
- } else {
893
- if (!this.#scope.isChild) {
894
- return Promise.reject(new MissingScopeContextError(this.#getTokenName(binding.token)));
736
+ return owner.#resolveBindingAsync(binding, undefined, resolutionPath, resolutionStack, branchDepth);
895
737
  }
738
+ } else if (this.#scope.isChild) {
896
739
  if (this.#scope.hasScoped(binding.id)) {
897
- return Promise.resolve(this.#scope.getScoped<Value>(binding.id));
740
+ return Promise.resolve(this.#scope.getScoped(binding.id));
898
741
  }
742
+ } else {
743
+ // Not `#readScoped`: this entry point reports failure as a rejection, never a sync throw.
744
+ return Promise.reject(new MissingScopeContextError(tokenName(binding.token)));
899
745
  }
900
- return this.#resolveBindingAsync(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
746
+ return this.#resolveBindingAsync(binding, undefined, resolutionPath, resolutionStack, branchDepth);
901
747
  }
902
748
 
903
749
  async resolveAsync<const Value>(
@@ -905,178 +751,125 @@ export class DependencyResolver {
905
751
  options: ResolveOptions | undefined,
906
752
  resolutionPath: Array<string>,
907
753
  resolutionStack: Array<ResolutionFrame>,
754
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
908
755
  ): Promise<Value> {
909
- const found = this.#findBinding(token, options, resolutionPath, resolutionStack);
910
-
911
- if (found === undefined) {
912
- const ownBindings = this.#registry.getAll(token);
913
- if (ownBindings.length > 0) {
914
- throw new NoMatchingBindingError(this.#getTokenName(token), options ?? {}, this.#getAvailableSlots(token));
915
- }
916
- throw new TokenNotBoundError(this.#getTokenName(token));
917
- }
918
-
919
- let currentToken: Token<unknown> | Constructor = token;
920
- let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
921
- let aliasFollowed: DefaultLookupEntry | undefined = found;
922
- while (aliasFollowed !== undefined && aliasFollowed.binding.kind === "alias") {
923
- const target = aliasFollowed.binding.target;
924
- visitedAliasTokens ??= new Set([currentToken]);
925
- if (visitedAliasTokens.has(target)) {
926
- throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
927
- }
928
- visitedAliasTokens.add(target);
929
- currentToken = target;
930
- aliasFollowed = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
931
- }
932
- if (aliasFollowed === undefined) {
933
- const ownBindings = this.#registry.getAll(currentToken);
934
- if (ownBindings.length > 0) {
935
- throw new NoMatchingBindingError(
936
- this.#getTokenName(currentToken),
937
- options ?? {},
938
- this.#getAvailableSlots(currentToken),
939
- );
940
- }
941
- throw new TokenNotBoundError(this.#getTokenName(currentToken));
942
- }
943
- const { binding, owner } = aliasFollowed;
756
+ const { binding, owner } = this.#requireBinding(token, options, resolutionPath, resolutionStack);
944
757
 
945
- const scope = (binding as BindingWithScope).scope ?? "transient";
946
-
947
- if (scope === "singleton" && owner !== this) {
948
- return owner.#resolveBindingAsync(binding as Binding<Value>, options, resolutionPath, resolutionStack);
758
+ if (binding.scope === "singleton" && owner !== this) {
759
+ return owner.#resolveBindingAsync(binding, options, resolutionPath, resolutionStack, branchDepth) as Value;
949
760
  }
950
-
951
- return this.#resolveBindingAsync(binding as Binding<Value>, options, resolutionPath, resolutionStack);
761
+ return this.#resolveBindingAsync(binding, options, resolutionPath, resolutionStack, branchDepth) as Value;
952
762
  }
953
763
 
954
- async #resolveBindingAsync<const Value>(
955
- binding: Binding<Value>,
764
+ async #resolveBindingAsync(
765
+ binding: Binding,
956
766
  options: ResolveOptions | undefined,
957
767
  resolutionPath: Array<string>,
958
768
  resolutionStack: Array<ResolutionFrame>,
959
- ): Promise<Value> {
960
- if (
961
- binding.kind === "constant" &&
962
- binding.onActivation === undefined &&
963
- !this.#lifecycle.hasActivationHandlers(binding.token)
964
- ) {
769
+ branchDepth: BranchDepth,
770
+ ): Promise<unknown> {
771
+ if (this.#isPlainConstant(binding)) {
965
772
  return binding.value;
966
773
  }
967
774
 
968
- const scope = (binding as BindingWithScope).scope ?? "transient";
969
-
970
- // Singleton cache
775
+ const scope = binding.scope;
971
776
  if (scope === "singleton") {
972
- if (this.#scope.hasSingleton(binding.id)) {
973
- return this.#scope.getSingleton<Value>(binding.id);
777
+ if (binding.instance !== NO_INSTANCE) {
778
+ return binding.instance;
974
779
  }
975
- // In-flight dedup
780
+ // In-flight dedup: concurrent callers share the first creation.
976
781
  const inflight = this.#scope.getInflight(binding.id);
977
782
  if (inflight !== undefined) {
978
- return inflight as Promise<Value>;
979
- }
980
- }
981
-
982
- // Scoped cache
983
- if (scope === "scoped") {
984
- if (!this.#scope.isChild) {
985
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
783
+ return inflight;
986
784
  }
987
- if (this.#scope.hasScoped(binding.id)) {
988
- return this.#scope.getScoped<Value>(binding.id);
785
+ } else if (scope === "scoped") {
786
+ const cachedScoped = this.#readScoped(binding);
787
+ if (cachedScoped !== SCOPED_MISS) {
788
+ return cachedScoped;
989
789
  }
990
790
  }
991
791
 
992
792
  const frame = this.#getResolutionFrame(binding);
993
- const frameName = frame.tokenName;
994
- const resolutionSet = enterResolutionPath(resolutionPath, frameName, false);
995
- resolutionStack.push(frame);
996
- const needsActivation = this.#needsActivation(binding);
793
+ // This level appends to its own branch and never unwinds — see ARCHITECTURE.md.
794
+ const levelPath = extendResolutionBranch(resolutionPath, branchDepth, frame.tokenName);
795
+ const levelStack = extendResolutionStackBranch(resolutionStack, branchDepth, frame);
796
+ const levelDepth = branchDepthOf(levelPath);
797
+
798
+ const needsActivation = this.#activation.needsActivation(binding);
997
799
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
998
- const resolutionCtx = new DefaultResolutionContext(
999
- this as unknown as ResolverCallbacks,
1000
- resolutionPath,
1001
- resolutionStack,
1002
- options,
1003
- );
1004
- try {
1005
- if (binding.kind === "dynamic-async") {
1006
- return await binding.factory(resolutionCtx);
1007
- }
1008
- const dynamicResult = binding.factory(resolutionCtx);
1009
- return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
1010
- } finally {
1011
- resolutionStack.pop();
1012
- resolutionPath.pop();
1013
- resolutionSet?.delete(frameName);
800
+ const resolutionCtx = new AsyncLevelContext(this, levelPath, levelStack, options);
801
+ if (binding.kind === "dynamic-async") {
802
+ return await binding.factory(resolutionCtx);
1014
803
  }
804
+ const dynamicResult = binding.factory(resolutionCtx);
805
+ return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
1015
806
  }
1016
807
 
1017
- const needsResolutionContext = needsActivation || this.#requiresResolutionContext(binding);
1018
- const resolutionCtx = needsResolutionContext
1019
- ? new DefaultResolutionContext(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, options)
1020
- : undefined;
808
+ const resolutionCtx =
809
+ needsActivation || requiresResolutionContext(binding)
810
+ ? new AsyncLevelContext(this, levelPath, levelStack, options)
811
+ : undefined;
1021
812
 
1022
- try {
1023
- if (scope === "singleton") {
1024
- const createSingletonPromise = async (): Promise<Value> => {
1025
- const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
1026
-
1027
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
1028
- const activated = shouldActivate
1029
- ? await this.#lifecycle.runActivation(
1030
- resolutionCtx as DefaultResolutionContext,
1031
- binding,
1032
- instance,
1033
- this.#metadataReader,
1034
- )
1035
- : instance;
1036
-
1037
- this.#scope.setSingleton(binding.id, activated);
813
+ if (scope === "singleton") {
814
+ // The promise is published before it settles, so concurrent callers dedup onto it.
815
+ const singletonPromise = this.#instantiateAndActivateAsync(
816
+ binding,
817
+ resolutionCtx,
818
+ levelPath,
819
+ levelStack,
820
+ levelDepth,
821
+ needsActivation,
822
+ ).then(
823
+ (activated) => {
824
+ this.#scope.setSingleton(binding, activated);
1038
825
  this.#scope.clearInflight(binding.id);
1039
826
  return activated;
1040
- };
1041
-
1042
- const singletonPromise = createSingletonPromise().catch((err: unknown) => {
827
+ },
828
+ (error: unknown) => {
1043
829
  this.#scope.clearInflight(binding.id);
1044
- throw err;
1045
- });
1046
- this.#scope.setInflight(binding.id, singletonPromise as Promise<unknown>);
1047
- return await singletonPromise;
1048
- }
1049
-
1050
- const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
1051
-
1052
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
1053
- const activated = shouldActivate
1054
- ? await this.#lifecycle.runActivation(
1055
- resolutionCtx as DefaultResolutionContext,
1056
- binding,
1057
- instance,
1058
- this.#metadataReader,
1059
- )
1060
- : instance;
1061
-
1062
- if (scope === "scoped") {
1063
- this.#scope.setScoped(binding.id, activated);
1064
- }
830
+ throw error;
831
+ },
832
+ );
833
+ this.#scope.setInflight(binding.id, singletonPromise as Promise<unknown>);
834
+ return await singletonPromise;
835
+ }
1065
836
 
1066
- return activated;
1067
- } finally {
1068
- resolutionStack.pop();
1069
- resolutionPath.pop();
1070
- resolutionSet?.delete(frameName);
837
+ const activated = await this.#instantiateAndActivateAsync(
838
+ binding,
839
+ resolutionCtx,
840
+ levelPath,
841
+ levelStack,
842
+ levelDepth,
843
+ needsActivation,
844
+ );
845
+ if (scope === "scoped") {
846
+ this.#scope.setScoped(binding.id, activated);
1071
847
  }
848
+ return activated;
1072
849
  }
1073
850
 
1074
- async #instantiateAsync<const Value>(
1075
- binding: Binding<Value>,
1076
- ctx: DefaultResolutionContext | undefined,
851
+ async #instantiateAndActivateAsync(
852
+ binding: Binding,
853
+ ctx: AsyncLevelContext | undefined,
1077
854
  resolutionPath: Array<string>,
1078
855
  resolutionStack: Array<ResolutionFrame>,
1079
- ): Promise<Value> {
856
+ branchDepth: BranchDepth,
857
+ needsActivation: boolean,
858
+ ): Promise<unknown> {
859
+ const instance = await this.#instantiateAsync(binding, ctx, resolutionPath, resolutionStack, branchDepth);
860
+ if (!this.#activation.refreshAfterFirstInstantiation(binding, needsActivation)) {
861
+ return instance;
862
+ }
863
+ return this.#lifecycle.runActivation(ctx as AsyncLevelContext, binding, instance, this.#metadataReader);
864
+ }
865
+
866
+ async #instantiateAsync(
867
+ binding: Binding,
868
+ ctx: AsyncLevelContext | undefined,
869
+ resolutionPath: Array<string>,
870
+ resolutionStack: Array<ResolutionFrame>,
871
+ branchDepth: BranchDepth,
872
+ ): Promise<unknown> {
1080
873
  switch (binding.kind) {
1081
874
  case "constant":
1082
875
  return binding.value;
@@ -1096,22 +889,26 @@ export class DependencyResolver {
1096
889
  return binding.factory(ctx);
1097
890
 
1098
891
  case "class": {
1099
- const deps = await this.#resolveClassDepsAsync(binding.target, resolutionPath, resolutionStack);
1100
- const instance = this.#instantiateClass(binding.target, deps);
1101
- return instance as Value;
892
+ const deps = await this.#resolveDepsAsync(
893
+ this.#constructorParams(binding.target),
894
+ resolutionPath,
895
+ resolutionStack,
896
+ branchDepth,
897
+ );
898
+ return this.#classes.instantiate(binding.target, deps);
1102
899
  }
1103
900
 
1104
901
  case "resolved": {
1105
902
  if (ctx === undefined) {
1106
903
  throw new InternalError("resolved binding requires resolution context");
1107
904
  }
1108
- const deps = await this.#resolveDescriptorDepsAsync(binding.deps, resolutionPath, resolutionStack);
905
+ const deps = await this.#resolveDepsAsync(binding.deps, resolutionPath, resolutionStack, branchDepth);
1109
906
  const factoryResult = binding.factory(...deps);
1110
907
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1111
908
  }
1112
909
 
1113
910
  case "resolved-async": {
1114
- const deps = await this.#resolveDescriptorDepsAsync(binding.deps, resolutionPath, resolutionStack);
911
+ const deps = await this.#resolveDepsAsync(binding.deps, resolutionPath, resolutionStack, branchDepth);
1115
912
  return binding.factory(...deps);
1116
913
  }
1117
914
 
@@ -1120,114 +917,45 @@ export class DependencyResolver {
1120
917
  }
1121
918
  }
1122
919
 
1123
- async #resolveClassDepsAsync(
1124
- target: Constructor,
920
+ async #resolveDepsAsync(
921
+ deps: ReadonlyArray<DependencySlot>,
1125
922
  resolutionPath: Array<string>,
1126
923
  resolutionStack: Array<ResolutionFrame>,
924
+ branchDepth: BranchDepth,
1127
925
  ): Promise<Array<unknown>> {
1128
- const meta = this.#getConstructorMetadata(target);
1129
- if (meta === undefined) {
1130
- if (target.length === 0) {
1131
- return [];
1132
- }
1133
- throw new MissingMetadataError(target.name);
1134
- }
1135
- if (meta.params.length === 0) {
926
+ const count = deps.length;
927
+ if (count === 0) {
1136
928
  return [];
1137
929
  }
1138
- if (meta.params.length === 1) {
1139
- const param = meta.params[0]!;
1140
- const paramOptions = injectionSlotToResolveOptions(param);
1141
- if (param.multi) {
1142
- return [await this.resolveAllAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1143
- }
1144
- if (param.optional) {
1145
- return [await this.resolveOptionalAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1146
- }
1147
- if (paramOptions === undefined) {
1148
- return [await this.resolveAsyncFromContext(param.token, resolutionPath, resolutionStack)];
1149
- }
1150
- return [await this.resolveAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1151
- }
1152
- const pending = new Array<Promise<unknown>>(meta.params.length);
1153
- const shouldCloneContext = meta.params.length > 1;
1154
- for (let index = 0; index < meta.params.length; index += 1) {
1155
- const param = meta.params[index]!;
1156
- const paramOptions = injectionSlotToResolveOptions(param);
1157
- if (param.multi) {
1158
- pending[index] = this.resolveAllAsync(
1159
- param.token,
1160
- paramOptions,
1161
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1162
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1163
- );
1164
- } else if (param.optional) {
1165
- pending[index] = this.resolveOptionalAsync(
1166
- param.token,
1167
- paramOptions,
1168
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1169
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1170
- );
1171
- } else {
1172
- pending[index] =
1173
- paramOptions === undefined
1174
- ? this.resolveAsyncFromContext(
1175
- param.token,
1176
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1177
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1178
- )
1179
- : this.resolveAsync(
1180
- param.token,
1181
- paramOptions,
1182
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1183
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1184
- );
1185
- }
930
+ if (count === 1) {
931
+ return [await this.#resolveDepAsync(deps[0]!, resolutionPath, resolutionStack, branchDepth)];
932
+ }
933
+ // Siblings resolve concurrently and each extends the same branch, so the first appends in
934
+ // place and the rest copy the prefix — no caller has to isolate them.
935
+ const pending = new Array<Promise<unknown>>(count);
936
+ for (let index = 0; index < count; index += 1) {
937
+ pending[index] = this.#resolveDepAsync(deps[index]!, resolutionPath, resolutionStack, branchDepth);
1186
938
  }
1187
939
  return Promise.all(pending);
1188
940
  }
1189
941
 
1190
- async #resolveDescriptorDepsAsync(
1191
- deps: ReadonlyArray<InjectionDescriptor>,
942
+ #resolveDepAsync(
943
+ dep: DependencySlot,
1192
944
  resolutionPath: Array<string>,
1193
945
  resolutionStack: Array<ResolutionFrame>,
1194
- ): Promise<Array<unknown>> {
1195
- const pending = new Array<Promise<unknown>>(deps.length);
1196
- const shouldCloneContext = deps.length > 1;
1197
- for (let index = 0; index < deps.length; index += 1) {
1198
- const dep = deps[index]!;
1199
- const depOptions = injectionSlotToResolveOptions(dep);
1200
- if (dep.multi) {
1201
- pending[index] = this.resolveAllAsync(
1202
- dep.token as Token<unknown> | Constructor,
1203
- depOptions,
1204
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1205
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1206
- );
1207
- } else if (dep.optional) {
1208
- pending[index] = this.resolveOptionalAsync(
1209
- dep.token as Token<unknown> | Constructor,
1210
- depOptions,
1211
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1212
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1213
- );
1214
- } else {
1215
- pending[index] =
1216
- depOptions === undefined
1217
- ? this.resolveAsyncFromContext(
1218
- dep.token as Token<unknown> | Constructor,
1219
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1220
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1221
- )
1222
- : this.resolveAsync(
1223
- dep.token as Token<unknown> | Constructor,
1224
- depOptions,
1225
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1226
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1227
- );
1228
- }
946
+ branchDepth: BranchDepth,
947
+ ): Promise<unknown> {
948
+ const options = injectionSlotToResolveOptions(dep);
949
+ if (dep.multi) {
950
+ return this.resolveAllAsync(dep.token, options, resolutionPath, resolutionStack, branchDepth);
1229
951
  }
1230
- return Promise.all(pending);
952
+ if (dep.optional) {
953
+ return this.resolveOptionalAsync(dep.token, options, resolutionPath, resolutionStack, branchDepth);
954
+ }
955
+ if (options === undefined) {
956
+ return this.resolveAsyncFromContext(dep.token, resolutionPath, resolutionStack, branchDepth);
957
+ }
958
+ return this.resolveAsync(dep.token, options, resolutionPath, resolutionStack, branchDepth);
1231
959
  }
1232
960
 
1233
961
  async resolveOptionalAsync<const Value>(
@@ -1235,11 +963,12 @@ export class DependencyResolver {
1235
963
  options: ResolveOptions | undefined,
1236
964
  resolutionPath: Array<string>,
1237
965
  resolutionStack: Array<ResolutionFrame>,
966
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
1238
967
  ): Promise<Value | undefined> {
1239
968
  if (this.#findBinding(token, options, resolutionPath, resolutionStack) === undefined) {
1240
969
  return undefined;
1241
970
  }
1242
- return this.resolveAsync(token, options, resolutionPath, resolutionStack);
971
+ return this.resolveAsync(token, options, resolutionPath, resolutionStack, branchDepth);
1243
972
  }
1244
973
 
1245
974
  async resolveAllAsync<const Value>(
@@ -1247,85 +976,88 @@ export class DependencyResolver {
1247
976
  options: ResolveOptions | undefined,
1248
977
  resolutionPath: Array<string>,
1249
978
  resolutionStack: Array<ResolutionFrame>,
979
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
1250
980
  ): Promise<Array<Value>> {
1251
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
1252
- const namedCandidates = this.#getSimpleNamedBindingsFromChain(token, options.name);
1253
- if (namedCandidates.length === 0) {
1254
- return [];
1255
- }
1256
- const pending = new Array<Promise<Value>>(namedCandidates.length);
1257
- for (let index = 0; index < namedCandidates.length; index += 1) {
1258
- pending[index] = this.#resolveCandidateAsync(
1259
- namedCandidates[index] as Binding<Value>,
1260
- options,
1261
- resolutionPath,
1262
- resolutionStack,
1263
- );
1264
- }
1265
- return Promise.all(pending);
1266
- }
1267
-
1268
- const allBindings = this.#getAllBindingsFromChain(token);
1269
- if (allBindings.length === 0) {
1270
- return [];
1271
- }
1272
-
1273
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
1274
- const candidates = selectAllBindings(allBindings, options, ctx);
1275
-
981
+ const candidates = this.#candidateBindings(token, options, resolutionPath, resolutionStack);
1276
982
  const pending = new Array<Promise<Value>>(candidates.length);
1277
983
  for (let index = 0; index < candidates.length; index += 1) {
1278
984
  pending[index] = this.#resolveCandidateAsync(
1279
- candidates[index] as Binding<Value>,
985
+ candidates[index]!,
1280
986
  options,
1281
987
  resolutionPath,
1282
988
  resolutionStack,
1283
- );
989
+ branchDepth,
990
+ ) as Promise<Value>;
1284
991
  }
1285
992
  return Promise.all(pending);
1286
993
  }
1287
994
 
1288
995
  // ── Helpers ────────────────────────────────────────────────────────────────
1289
996
 
1290
- #getAllBindingsFromChain(token: Token<unknown> | Constructor): ReadonlyArray<Binding> {
997
+ #allBindingsFromChain(token: Token<unknown> | Constructor): ReadonlyArray<Binding> {
1291
998
  const ownBindings = this.#registry.getAll(token);
1292
999
  if (this.#parent === undefined) {
1293
1000
  return ownBindings;
1294
1001
  }
1295
1002
  const result: Array<Binding> = [...ownBindings];
1296
- let current: DependencyResolver | undefined = this.#parent;
1297
- while (current !== undefined) {
1003
+ for (let current: DependencyResolver | undefined = this.#parent; current !== undefined; current = current.#parent) {
1298
1004
  const own = current.#registry.getAll(token);
1299
1005
  if (own.length > 0) {
1300
1006
  result.push(...own);
1301
1007
  }
1302
- current = current.#parent;
1303
1008
  }
1304
1009
  return result;
1305
1010
  }
1306
1011
 
1307
- #getSimpleNamedBindingsFromChain(token: Token<unknown> | Constructor, name: string): Array<Binding> {
1012
+ /** Every binding the chain's name indexes hold for one name, nearest container first. */
1013
+ #namedBindingsFromChain(token: Token<unknown> | Constructor, name: string): Array<Binding> {
1014
+ // A name resolves to at most one binding per registry, so a root container's answer is built
1015
+ // whole rather than grown — the list is sized at its allocation.
1308
1016
  const ownBinding = this.#registry.getSimpleNamed(token, name);
1309
1017
  if (this.#parent === undefined) {
1310
- return ownBinding !== undefined ? [ownBinding] : [];
1311
- }
1312
- const result: Array<Binding> = [];
1313
- if (ownBinding !== undefined) {
1314
- result.push(ownBinding);
1018
+ return ownBinding === undefined ? [] : [ownBinding];
1315
1019
  }
1316
- let current: DependencyResolver | undefined = this.#parent;
1317
- while (current !== undefined) {
1020
+ const result: Array<Binding> = ownBinding === undefined ? [] : [ownBinding];
1021
+ for (let current: DependencyResolver | undefined = this.#parent; current !== undefined; current = current.#parent) {
1318
1022
  const binding = current.#registry.getSimpleNamed(token, name);
1319
1023
  if (binding !== undefined) {
1320
1024
  result.push(binding);
1321
1025
  }
1322
- current = current.#parent;
1323
1026
  }
1324
1027
  return result;
1325
1028
  }
1326
1029
 
1327
- #getAvailableSlots(token: Token<unknown> | Constructor): Array<string> {
1328
- return this.#registry.availableSlotStrings(token);
1030
+ /** A constant with no activation anywhere resolves to its value with no pipeline at all. */
1031
+ #isPlainConstant(binding: Binding): binding is ConstantBinding<unknown> {
1032
+ return (
1033
+ binding.kind === "constant" &&
1034
+ binding.onActivation === undefined &&
1035
+ (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
1036
+ );
1037
+ }
1038
+
1039
+ /** Whether either an own hook or a container-level hook would run for this binding. */
1040
+ #hasAnyActivation(binding: DynamicBinding<unknown> | DynamicAsyncBinding<unknown>): boolean {
1041
+ if (binding.onActivation !== undefined) {
1042
+ return true;
1043
+ }
1044
+ return this.#lifecycle.activationVersion !== 0 && this.#lifecycle.hasActivationHandlers(binding.token);
1045
+ }
1046
+
1047
+ /**
1048
+ * The cached instance of a `scoped` binding, or {@link SCOPED_MISS}.
1049
+ *
1050
+ * @remarks A `scoped` binding outside a child container is a configuration error, not a miss, so
1051
+ * the check lives with the read that depends on it.
1052
+ */
1053
+ #readScoped(binding: Binding): unknown {
1054
+ if (!this.#scope.isChild) {
1055
+ throw new MissingScopeContextError(tokenName(binding.token));
1056
+ }
1057
+ if (this.#scope.hasScoped(binding.id)) {
1058
+ return this.#scope.getScoped(binding.id);
1059
+ }
1060
+ return SCOPED_MISS;
1329
1061
  }
1330
1062
 
1331
1063
  #makeConstraintContext(
@@ -1336,13 +1068,11 @@ export class DependencyResolver {
1336
1068
  if (options === undefined && resolutionPath.length === 0 && resolutionStack.length === 0) {
1337
1069
  return ROOT_CONSTRAINT_CONTEXT;
1338
1070
  }
1339
- const parent = resolutionStack.at(-1);
1340
- const ancestors = resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [];
1341
1071
  return {
1342
1072
  resolutionPath,
1343
1073
  resolutionStack,
1344
- parent,
1345
- ancestors,
1074
+ parent: resolutionStack.at(-1),
1075
+ ancestors: resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [],
1346
1076
  currentResolveOptions: options,
1347
1077
  };
1348
1078
  }
@@ -1353,108 +1083,21 @@ export class DependencyResolver {
1353
1083
  resolutionPath: Array<string>,
1354
1084
  resolutionStack: Array<ResolutionFrame>,
1355
1085
  ): boolean {
1356
- if (!this.#matchesSlotFast(binding.slot, options)) {
1086
+ if (!matchesSlot(binding.slot, options)) {
1357
1087
  return false;
1358
1088
  }
1359
1089
  if (binding.predicate === undefined) {
1360
1090
  return true;
1361
1091
  }
1362
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
1363
- return binding.predicate(ctx);
1364
- }
1365
-
1366
- #matchesSlotFast(slot: BindingSlot, options: ResolveOptions | undefined): boolean {
1367
- const requestedName = options?.name;
1368
- const requestedTags = options?.tags;
1369
- const singleRequestedTag = options?.tag;
1370
- const hasRequestedTags = (requestedTags?.length ?? 0) > 0 || singleRequestedTag !== undefined;
1371
-
1372
- if (slot.name !== undefined) {
1373
- if (requestedName === undefined || slot.name !== requestedName) {
1374
- return false;
1375
- }
1376
- } else if (requestedName !== undefined) {
1377
- return false;
1378
- }
1379
-
1380
- if (slot.tags.length > 0) {
1381
- if (!hasRequestedTags) {
1382
- return false;
1383
- }
1384
- for (const [tagKey, tagValue] of slot.tags) {
1385
- if (!this.#matchesRequestedTag(tagKey, tagValue, requestedTags, singleRequestedTag)) {
1386
- return false;
1387
- }
1388
- }
1389
- } else if (hasRequestedTags) {
1390
- return false;
1391
- }
1392
-
1393
- return true;
1092
+ return binding.predicate(this.#makeConstraintContext(resolutionPath, resolutionStack, options));
1394
1093
  }
1395
1094
 
1396
- #getTokenName(token: Token<unknown> | Constructor): string {
1397
- return tokenName(token);
1398
- }
1399
-
1400
- #getConstructorMetadata(target: Constructor): ConstructorMetadata | undefined {
1401
- const cached = this.#classConstructorMetadata.get(target);
1402
- if (cached !== undefined) {
1403
- return cached === null ? undefined : cached;
1404
- }
1405
- const metadata = this.#metadataReader.getConstructorMetadata(target);
1406
- this.#classConstructorMetadata.set(target, metadata ?? null);
1407
- return metadata;
1408
- }
1409
-
1410
- #instantiateClass(target: Constructor, deps: Array<unknown>): unknown {
1411
- let needsActiveContainer = this.#classNeedsActiveContainer.get(target);
1412
- if (needsActiveContainer === undefined) {
1413
- const accessorMetadata = this.#metadataReader.getAccessorMetadata?.(target);
1414
- needsActiveContainer = (accessorMetadata?.length ?? 0) > 0;
1415
- this.#classNeedsActiveContainer.set(target, needsActiveContainer);
1416
- }
1417
- const invokable = target as ConstructorInvocation;
1418
- if (!needsActiveContainer) {
1419
- return new invokable(...deps);
1420
- }
1421
- return runWithContainer(this.#container, () => new invokable(...deps));
1422
- }
1423
-
1424
- #matchesRequestedTag(
1425
- tagKey: string,
1426
- tagValue: unknown,
1427
- requestedTags: ReadonlyArray<BindingTag> | undefined,
1428
- singleRequestedTag: BindingTag | undefined,
1429
- ): boolean {
1430
- if (
1431
- singleRequestedTag !== undefined &&
1432
- singleRequestedTag[0] === tagKey &&
1433
- Object.is(singleRequestedTag[1], tagValue)
1434
- ) {
1435
- return true;
1436
- }
1437
- if (requestedTags === undefined || requestedTags.length === 0) {
1438
- return false;
1439
- }
1440
- for (let index = 0; index < requestedTags.length; index += 1) {
1441
- const requestedTag = requestedTags[index]!;
1442
- if (requestedTag[0] === tagKey && Object.is(requestedTag[1], tagValue)) {
1443
- return true;
1444
- }
1445
- }
1446
- return false;
1447
- }
1448
-
1449
- #resolveTransientDynamicSyncFromContext<const Value>(
1450
- binding: Binding<Value> & { kind: "dynamic" },
1095
+ #resolveTransientDynamicSyncFromContext(
1096
+ binding: DynamicBinding<unknown>,
1451
1097
  resolutionPath: Array<string>,
1452
1098
  resolutionStack: Array<ResolutionFrame>,
1453
- ): Value {
1454
- // One lane at every depth. The separate deep lane existed because cycle detection used to be
1455
- // an O(depth) `resolutionPath.includes()` scan, which had to be escaped past ~32 levels; with
1456
- // the O(1) `binding.inFlight` mark there is nothing to escape, so the depth split — and the
1457
- // divergent behaviour it caused — is gone.
1099
+ ): unknown {
1100
+ // One lane at every depth: `binding.inFlight` is O(1), so there is nothing to escape.
1458
1101
  const frame = this.#getResolutionFrame(binding);
1459
1102
  const tokenDisplayName = frame.tokenName;
1460
1103
  if (binding.inFlight) {
@@ -1477,180 +1120,149 @@ export class DependencyResolver {
1477
1120
  }
1478
1121
  }
1479
1122
 
1480
- // NOT declared `async` — avoids creating a JSAsyncGeneratorObject + implicit Promise wrapper on
1481
- // every invocation. Cleanup is handled via .then(onFulfilled, onRejected) so the behaviour is
1482
- // identical to a try/finally but without the async machinery overhead.
1483
- #resolveTransientDynamicAsyncFromContext<const Value>(
1484
- binding: Binding<Value> & { kind: "dynamic" | "dynamic-async" },
1123
+ // Deliberately not `async`: that would allocate a state machine and a promise per level.
1124
+ #resolveTransientDynamicAsyncFromContext(
1125
+ binding: DynamicBinding<unknown> | DynamicAsyncBinding<unknown>,
1485
1126
  resolutionPath: Array<string>,
1486
1127
  resolutionStack: Array<ResolutionFrame>,
1487
- ): Promise<Value> {
1488
- // One lane at every depth. Cycle detection goes through `enterResolutionPath`, which is
1489
- // path-scoped — the only mechanism that stays correct when chains interleave (Promise.all) —
1490
- // and adapts on its own: a linear scan while the path is short, an attached Set past
1491
- // RESOLUTION_SET_THRESHOLD. That removes the depth split, and with it a silent change of
1492
- // behaviour (context identity, stack frames, promise shape) at the old threshold.
1493
- //
1494
- // For a sequential chain every level shares one resolutionPath/resolutionStack, so a single
1495
- // DefaultResolutionContext serves the whole chain: inner levels of the owning chain allocate
1496
- // nothing. A concurrent chain is detected by path identity and gets its own context.
1128
+ branchDepth: BranchDepth,
1129
+ ): Promise<unknown> {
1497
1130
  const frame = this.#getResolutionFrame(binding);
1498
- const tokenDisplayName = frame.tokenName;
1131
+ let levelPath: OwnedBranchPath;
1499
1132
  try {
1500
- enterResolutionPath(resolutionPath, tokenDisplayName, false);
1133
+ levelPath = extendResolutionBranch(resolutionPath, branchDepth, frame.tokenName);
1501
1134
  } catch (cycleError) {
1502
1135
  // This method is not `async`; keep failures as rejections rather than sync throws.
1503
1136
  return Promise.reject(cycleError);
1504
1137
  }
1138
+ const levelStack = extendResolutionStackBranch(resolutionStack, branchDepth, frame);
1505
1139
 
1506
- let ctx: DefaultResolutionContext;
1507
- let isOwnerLevel: boolean;
1508
- if (this.#asyncChainCtxPath === resolutionPath) {
1509
- ctx = this.#asyncChainCtx!;
1510
- isOwnerLevel = true;
1511
- } else if (this.#asyncChainCtxPath === undefined) {
1512
- const existing = this.#asyncChainCtx;
1513
- if (existing === undefined) {
1514
- ctx = new DefaultResolutionContext(
1515
- this as unknown as ResolverCallbacks,
1516
- resolutionPath,
1517
- resolutionStack,
1518
- undefined,
1519
- );
1520
- this.#asyncChainCtx = ctx;
1521
- } else {
1522
- existing.reset(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, undefined);
1523
- ctx = existing;
1140
+ // Nothing this level appended is ever removed, so no level observes its own settlement.
1141
+ const ctx = new AsyncLevelContext(this, levelPath, levelStack, undefined);
1142
+ try {
1143
+ if (binding.kind === "dynamic-async") {
1144
+ return binding.factory(ctx);
1524
1145
  }
1525
- this.#asyncChainCtxPath = resolutionPath;
1526
- isOwnerLevel = true;
1527
- } else {
1528
- ctx = new DefaultResolutionContext(
1529
- this as unknown as ResolverCallbacks,
1530
- resolutionPath,
1531
- resolutionStack,
1532
- undefined,
1533
- );
1534
- isOwnerLevel = false;
1146
+ const factoryResult = binding.factory(ctx);
1147
+ return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1148
+ } catch (factoryError) {
1149
+ return Promise.reject(factoryError);
1150
+ }
1151
+ }
1152
+
1153
+ // ── The cascade lane ───────────────────────────────────────────────────────
1154
+
1155
+ /**
1156
+ * Entry for a request a factory makes from inside an open synchronous cascade.
1157
+ *
1158
+ * @remarks A request arriving with no cascade open came out of a continuation, so its ancestors
1159
+ * are on no call stack — it escapes to the branch lane. See `ARCHITECTURE.md`.
1160
+ */
1161
+ resolveAsyncFromCascade(token: Token<unknown> | Constructor): Promise<unknown> {
1162
+ if (this.#cascadePath.length === 0) {
1163
+ return this.resolveAsyncFromContext(token, [], [], ROOT_BRANCH);
1535
1164
  }
1165
+ return this.#dispatchCascade(token);
1166
+ }
1536
1167
 
1537
- if (isOwnerLevel) {
1538
- this.#asyncChainActiveLevels++;
1168
+ /** Entry for a resolve the container starts, which opens the cascade rather than joining one. */
1169
+ resolveAsyncFromRoot(token: Token<unknown> | Constructor): Promise<unknown> {
1170
+ return this.#dispatchCascade(token);
1171
+ }
1172
+
1173
+ #dispatchCascade(token: Token<unknown> | Constructor): Promise<unknown> {
1174
+ const fastBinding = this.#registry.getFastDefault(token);
1175
+ if (fastBinding !== undefined) {
1176
+ if (
1177
+ (fastBinding.kind === "dynamic-async" || fastBinding.kind === "dynamic") &&
1178
+ fastBinding.scope === "transient" &&
1179
+ !this.#hasAnyActivation(fastBinding)
1180
+ ) {
1181
+ return this.#resolveTransientDynamicAsyncCascade(fastBinding);
1182
+ }
1183
+ // A value that already exists answers here: escaping would snapshot the cascade for a resolve
1184
+ // that never looks at a path.
1185
+ if (this.#isPlainConstant(fastBinding)) {
1186
+ return Promise.resolve(fastBinding.value);
1187
+ }
1188
+ if (fastBinding.scope === "singleton" && fastBinding.instance !== NO_INSTANCE) {
1189
+ return Promise.resolve(fastBinding.instance);
1190
+ }
1539
1191
  }
1192
+ // Anything else leaves the cascade lane for good, seeded with a snapshot of the ancestors it
1193
+ // accumulated — so a cycle across the boundary is still on one path.
1194
+ return this.resolveAsyncFromContext(token, [...this.#cascadePath], [...this.#cascadeStack], UNOWNED_BRANCH);
1195
+ }
1540
1196
 
1541
- // Invoke the factory synchronously to get its Promise (or a resolved value for "dynamic").
1542
- let factoryPromise: Promise<Value>;
1197
+ #resolveTransientDynamicAsyncCascade(
1198
+ binding: DynamicBinding<unknown> | DynamicAsyncBinding<unknown>,
1199
+ ): Promise<unknown> {
1200
+ const frame = this.#getResolutionFrame(binding);
1201
+ // The request that closes a cycle is made from a factory's synchronous prefix, and synchronous
1202
+ // code does not interleave — so the O(1) flag is exact path membership here, as it is for the
1203
+ // sync lane. It is cleared when the factory returns its promise, not when that promise settles.
1204
+ if (binding.inFlight) {
1205
+ return Promise.reject(new CircularDependencyError([...this.#cascadePath, frame.tokenName]));
1206
+ }
1207
+ const ctx = (this.#cascadeContext ??= new AsyncCascadeContext(this, this.#cascadePath, this.#cascadeStack));
1208
+ binding.inFlight = true;
1209
+ this.#cascadePath.push(frame.tokenName);
1210
+ this.#cascadeStack.push(frame);
1543
1211
  try {
1544
1212
  if (binding.kind === "dynamic-async") {
1545
- factoryPromise = binding.factory(ctx);
1546
- } else {
1547
- const factoryResult = binding.factory(ctx);
1548
- factoryPromise =
1549
- factoryResult instanceof Promise ? (factoryResult as Promise<Value>) : Promise.resolve(factoryResult);
1213
+ return binding.factory(ctx);
1550
1214
  }
1215
+ const factoryResult = binding.factory(ctx);
1216
+ return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1551
1217
  } catch (factoryError) {
1552
- // Synchronous throw from the factory (rare) — clean up immediately.
1553
- exitResolutionPath(resolutionPath);
1554
- if (isOwnerLevel && --this.#asyncChainActiveLevels === 0) {
1555
- this.#asyncChainCtxPath = undefined;
1556
- this.#asyncChainSettle = undefined;
1557
- }
1558
1218
  return Promise.reject(factoryError);
1219
+ } finally {
1220
+ this.#cascadeStack.pop();
1221
+ this.#cascadePath.pop();
1222
+ binding.inFlight = false;
1559
1223
  }
1560
-
1561
- // Cleanup runs as a SIDE listener on the factory promise instead of a derived-promise chain:
1562
- // registered synchronously here, it is FIFO-guaranteed to run before the awaiting caller
1563
- // resumes, so ordering is identical while saving one intermediate promise and one microtask
1564
- // hop per level. Trade-off: the settle handler marks a rejection as handled, so an unawaited
1565
- // failing resolveAsync no longer surfaces as an unhandledRejection — callers are expected to
1566
- // await (or .catch) the returned promise.
1567
- //
1568
- // Every level of the owning chain unwinds identically, so one closure serves them all.
1569
- let settle: () => void;
1570
- if (isOwnerLevel) {
1571
- settle =
1572
- this.#asyncChainSettle ??
1573
- (this.#asyncChainSettle = (): void => {
1574
- exitResolutionPath(resolutionPath);
1575
- if (--this.#asyncChainActiveLevels === 0) {
1576
- this.#asyncChainCtxPath = undefined;
1577
- this.#asyncChainSettle = undefined;
1578
- }
1579
- });
1580
- } else {
1581
- settle = (): void => {
1582
- exitResolutionPath(resolutionPath);
1583
- };
1584
- }
1585
- factoryPromise.then(settle, settle);
1586
- return factoryPromise;
1587
1224
  }
1588
1225
 
1589
- #resolveCandidateSync<const Value>(
1590
- binding: Binding<Value>,
1226
+ // A cached candidate answers here rather than re-entering the generic path: `resolveAll` pays
1227
+ // this per candidate, and a fan-out over cached handlers is the shape that makes it matter.
1228
+ #resolveCandidateSync(
1229
+ binding: Binding,
1591
1230
  options: ResolveOptions | undefined,
1592
1231
  resolutionPath: Array<string>,
1593
1232
  resolutionStack: Array<ResolutionFrame>,
1594
- ): Value {
1595
- if (
1596
- binding.kind === "constant" &&
1597
- binding.onActivation === undefined &&
1598
- !this.#lifecycle.hasActivationHandlers(binding.token)
1599
- ) {
1233
+ ): unknown {
1234
+ if (this.#isPlainConstant(binding)) {
1600
1235
  return binding.value;
1601
1236
  }
1602
1237
  if (binding.kind === "alias") {
1603
1238
  return this.resolve(binding.target, options, resolutionPath, resolutionStack);
1604
1239
  }
1605
- const scope = (binding as BindingWithScope).scope ?? "transient";
1606
- if (scope === "singleton" && this.#scope.hasSingleton(binding.id)) {
1607
- return this.#scope.getSingleton<Value>(binding.id);
1608
- }
1609
- if (scope === "scoped") {
1610
- if (!this.#scope.isChild) {
1611
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
1612
- }
1613
- if (this.#scope.hasScoped(binding.id)) {
1614
- return this.#scope.getScoped<Value>(binding.id);
1615
- }
1240
+ if (binding.scope === "singleton" && binding.instance !== NO_INSTANCE) {
1241
+ return binding.instance;
1616
1242
  }
1617
1243
  return this.#resolveBinding(binding, options, resolutionPath, resolutionStack);
1618
1244
  }
1619
1245
 
1620
- #resolveCandidateAsync<const Value>(
1621
- binding: Binding<Value>,
1246
+ #resolveCandidateAsync(
1247
+ binding: Binding,
1622
1248
  options: ResolveOptions | undefined,
1623
1249
  resolutionPath: Array<string>,
1624
1250
  resolutionStack: Array<ResolutionFrame>,
1625
- ): Promise<Value> {
1626
- if (
1627
- binding.kind === "constant" &&
1628
- binding.onActivation === undefined &&
1629
- !this.#lifecycle.hasActivationHandlers(binding.token)
1630
- ) {
1251
+ branchDepth: BranchDepth,
1252
+ ): Promise<unknown> {
1253
+ if (this.#isPlainConstant(binding)) {
1631
1254
  return Promise.resolve(binding.value);
1632
1255
  }
1633
- const isolatedPath = [...resolutionPath];
1634
- const isolatedStack = [...resolutionStack];
1635
1256
  if (binding.kind === "alias") {
1636
- return this.resolveAsync(binding.target, options, isolatedPath, isolatedStack);
1257
+ return this.resolveAsync(binding.target, options, resolutionPath, resolutionStack, branchDepth);
1637
1258
  }
1638
- const scope = (binding as BindingWithScope).scope ?? "transient";
1639
- if (scope === "singleton" && this.#scope.hasSingleton(binding.id)) {
1640
- return Promise.resolve(this.#scope.getSingleton<Value>(binding.id));
1259
+ if (binding.scope === "singleton" && binding.instance !== NO_INSTANCE) {
1260
+ return Promise.resolve(binding.instance);
1641
1261
  }
1642
- if (scope === "scoped") {
1643
- if (!this.#scope.isChild) {
1644
- return Promise.reject(new MissingScopeContextError(this.#getTokenName(binding.token)));
1645
- }
1646
- if (this.#scope.hasScoped(binding.id)) {
1647
- return Promise.resolve(this.#scope.getScoped<Value>(binding.id));
1648
- }
1649
- }
1650
- return this.#resolveBindingAsync(binding, options, isolatedPath, isolatedStack);
1262
+ return this.#resolveBindingAsync(binding, options, resolutionPath, resolutionStack, branchDepth);
1651
1263
  }
1652
1264
 
1653
- #getResolutionFrame<const Value>(binding: Binding<Value>): ResolutionFrame {
1265
+ #getResolutionFrame(binding: Binding): ResolutionFrame {
1654
1266
  // Memoized on the binding rather than in a per-resolver Map: the frame derives only from
1655
1267
  // immutable binding fields, so it is identical for every resolver, and a field read beats a
1656
1268
  // Map lookup on every hop of a chain.
@@ -1658,80 +1270,11 @@ export class DependencyResolver {
1658
1270
  if (existing !== undefined) {
1659
1271
  return existing;
1660
1272
  }
1661
- const scope = (binding as BindingWithScope).scope ?? "transient";
1662
- const frame = buildResolutionFrame(tokenName(binding.token), scope, binding.id, binding.kind, binding.slot);
1273
+ const frame = buildResolutionFrame(tokenName(binding.token), binding.scope, binding.id, binding.kind, binding.slot);
1663
1274
  binding.frame = frame;
1664
1275
  return frame;
1665
1276
  }
1666
1277
 
1667
- #needsActivation<const Value>(binding: Binding<Value>): boolean {
1668
- const lifecycleVersion = this.#lifecycle.activationVersion;
1669
- if (
1670
- lifecycleVersion === 0 &&
1671
- binding.kind !== "class" &&
1672
- binding.kind !== "alias" &&
1673
- binding.onActivation === undefined
1674
- ) {
1675
- return false;
1676
- }
1677
- if (this.#activationCacheVersion !== lifecycleVersion) {
1678
- this.#activationNeedByBindingId.clear();
1679
- this.#activationCacheVersion = lifecycleVersion;
1680
- }
1681
-
1682
- const cached = this.#activationNeedByBindingId.get(binding.id);
1683
- if (cached !== undefined) {
1684
- return cached;
1685
- }
1686
-
1687
- if (binding.kind === "class") {
1688
- let hasActivation = this.#lifecycle.hasActivationHandlers(binding.token) || binding.onActivation !== undefined;
1689
- const cachedPostConstruct = this.#classHasPostConstruct.get(binding.target);
1690
- // Unknown class lifecycle metadata: activate once, then cache after first instantiation.
1691
- if (cachedPostConstruct === undefined) {
1692
- hasActivation = true;
1693
- } else if (cachedPostConstruct) {
1694
- hasActivation = true;
1695
- }
1696
- this.#activationNeedByBindingId.set(binding.id, hasActivation);
1697
- return hasActivation;
1698
- }
1699
-
1700
- let hasActivation = false;
1701
- if (binding.kind !== "alias" && binding.onActivation !== undefined) {
1702
- hasActivation = true;
1703
- } else if (this.#lifecycle.hasActivationHandlers(binding.token)) {
1704
- hasActivation = true;
1705
- }
1706
-
1707
- this.#activationNeedByBindingId.set(binding.id, hasActivation);
1708
- return hasActivation;
1709
- }
1710
-
1711
- #refreshClassPostConstructCache(target: Constructor): void {
1712
- const lifecycle = this.#metadataReader.getLifecycleMetadata(target);
1713
- const hasPostConstruct =
1714
- lifecycle !== undefined && lifecycle.postConstruct !== undefined && lifecycle.postConstruct.length > 0;
1715
- this.#classHasPostConstruct.set(target, hasPostConstruct);
1716
- }
1717
-
1718
- /**
1719
- * Refreshes the post-construct cache for class bindings on first instantiation and
1720
- * returns the (possibly updated) shouldActivate flag.
1721
- */
1722
- #refreshActivationCacheIfNeeded<Value>(binding: Binding<Value>, needsActivation: boolean): boolean {
1723
- if (binding.kind === "class" && this.#classHasPostConstruct.get(binding.target) === undefined) {
1724
- this.#refreshClassPostConstructCache(binding.target);
1725
- this.#activationNeedByBindingId.delete(binding.id);
1726
- return this.#needsActivation(binding);
1727
- }
1728
- return needsActivation;
1729
- }
1730
-
1731
- #requiresResolutionContext<const Value>(binding: Binding<Value>): boolean {
1732
- return binding.kind === "dynamic" || binding.kind === "dynamic-async";
1733
- }
1734
-
1735
1278
  #acquireSyncResolutionContext(
1736
1279
  resolutionPath: Array<string>,
1737
1280
  resolutionStack: Array<ResolutionFrame>,
@@ -1740,16 +1283,41 @@ export class DependencyResolver {
1740
1283
  const depth = resolutionStack.length;
1741
1284
  const existing = this.#syncResolutionContextPool[depth];
1742
1285
  if (existing !== undefined) {
1743
- existing.reset(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, options);
1286
+ existing.reset(this, resolutionPath, resolutionStack, options);
1744
1287
  return existing;
1745
1288
  }
1746
- const created = new DefaultResolutionContext(
1747
- this as unknown as ResolverCallbacks,
1748
- resolutionPath,
1749
- resolutionStack,
1750
- options,
1751
- );
1289
+ const created = new DefaultResolutionContext(this, resolutionPath, resolutionStack, options);
1752
1290
  this.#syncResolutionContextPool[depth] = created;
1753
1291
  return created;
1754
1292
  }
1755
1293
  }
1294
+
1295
+ /** Absent scoped entry — distinguishes it from a cached `undefined`. */
1296
+ const SCOPED_MISS: unique symbol = Symbol("di:scoped-miss");
1297
+
1298
+ function anyPredicate(bindings: ReadonlyArray<Binding>): boolean {
1299
+ for (let index = 0; index < bindings.length; index += 1) {
1300
+ if (bindings[index]!.predicate !== undefined) {
1301
+ return true;
1302
+ }
1303
+ }
1304
+ return false;
1305
+ }
1306
+
1307
+ /** Only a factory is handed the resolution context; everything else gets its deps directly. */
1308
+ function requiresResolutionContext(binding: Binding): boolean {
1309
+ return binding.kind === "dynamic" || binding.kind === "dynamic-async";
1310
+ }
1311
+
1312
+ /**
1313
+ * Whether the tag index's answer is the one `Object.is` would give.
1314
+ *
1315
+ * @remarks An indexed binding has no name, no predicate and exactly one tag, and the request carries
1316
+ * only that tag, so `matchesSlot` reduces to the tag values — and the index matched the key already.
1317
+ * It answers by SameValueZero, which parts from `Object.is` (SPEC §3.5) on exactly one pair: `+0` and
1318
+ * `-0`. So a request whose value is not zero is already exact, and only a zero-valued one is worth
1319
+ * reading the stored value for.
1320
+ */
1321
+ function matchesIndexedTagValue(binding: Binding, requestedValue: unknown): boolean {
1322
+ return requestedValue !== 0 || Object.is(binding.slot.tags[0]![1], requestedValue);
1323
+ }