@codefast/di 0.5.0-canary.8 → 0.5.0

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 (91) hide show
  1. package/CHANGELOG.md +387 -0
  2. package/README.md +3 -1
  3. package/dist/binding.d.ts +41 -22
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +11 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/container/binding-builders.d.ts.map +1 -1
  8. package/dist/container/binding-builders.js +8 -5
  9. package/dist/container/binding-builders.js.map +1 -1
  10. package/dist/container/container.d.ts.map +1 -1
  11. package/dist/container/container.js +67 -110
  12. package/dist/container/container.js.map +1 -1
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +13 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/introspection/inspector.d.ts +0 -1
  22. package/dist/introspection/inspector.d.ts.map +1 -1
  23. package/dist/introspection/inspector.js +2 -7
  24. package/dist/introspection/inspector.js.map +1 -1
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +46 -33
  27. package/dist/registry.js.map +1 -1
  28. package/dist/resolution/activation-need.d.ts +4 -2
  29. package/dist/resolution/activation-need.d.ts.map +1 -1
  30. package/dist/resolution/activation-need.js +15 -11
  31. package/dist/resolution/activation-need.js.map +1 -1
  32. package/dist/resolution/binding-lookup-cache.d.ts +1 -1
  33. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -1
  34. package/dist/resolution/binding-lookup-cache.js +18 -2
  35. package/dist/resolution/binding-lookup-cache.js.map +1 -1
  36. package/dist/resolution/binding-scope.d.ts +5 -2
  37. package/dist/resolution/binding-scope.d.ts.map +1 -1
  38. package/dist/resolution/binding-scope.js +6 -17
  39. package/dist/resolution/binding-scope.js.map +1 -1
  40. package/dist/resolution/binding-select.d.ts +8 -1
  41. package/dist/resolution/binding-select.d.ts.map +1 -1
  42. package/dist/resolution/binding-select.js +13 -32
  43. package/dist/resolution/binding-select.js.map +1 -1
  44. package/dist/resolution/diagnostics.d.ts +2 -2
  45. package/dist/resolution/diagnostics.d.ts.map +1 -1
  46. package/dist/resolution/environment.d.ts +47 -21
  47. package/dist/resolution/environment.d.ts.map +1 -1
  48. package/dist/resolution/environment.js +134 -31
  49. package/dist/resolution/environment.js.map +1 -1
  50. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  51. package/dist/resolution/instantiation-plan.js +1 -1
  52. package/dist/resolution/instantiation-plan.js.map +1 -1
  53. package/dist/resolution/lifecycle.d.ts.map +1 -1
  54. package/dist/resolution/lifecycle.js +46 -53
  55. package/dist/resolution/lifecycle.js.map +1 -1
  56. package/dist/resolution/resolution-path.d.ts +82 -6
  57. package/dist/resolution/resolution-path.d.ts.map +1 -1
  58. package/dist/resolution/resolution-path.js +66 -8
  59. package/dist/resolution/resolution-path.js.map +1 -1
  60. package/dist/resolution/resolve-options.d.ts +41 -4
  61. package/dist/resolution/resolve-options.d.ts.map +1 -1
  62. package/dist/resolution/resolve-options.js +25 -1
  63. package/dist/resolution/resolve-options.js.map +1 -1
  64. package/dist/resolution/resolver.d.ts +29 -11
  65. package/dist/resolution/resolver.d.ts.map +1 -1
  66. package/dist/resolution/resolver.js +417 -564
  67. package/dist/resolution/resolver.js.map +1 -1
  68. package/dist/resolution/scope.d.ts +4 -0
  69. package/dist/resolution/scope.d.ts.map +1 -1
  70. package/dist/resolution/scope.js +8 -0
  71. package/dist/resolution/scope.js.map +1 -1
  72. package/package.json +5 -11
  73. package/src/binding.ts +46 -23
  74. package/src/container/binding-builders.ts +8 -5
  75. package/src/container/container.ts +72 -119
  76. package/src/errors.ts +17 -0
  77. package/src/index.ts +3 -1
  78. package/src/introspection/inspector.ts +2 -8
  79. package/src/registry.ts +52 -35
  80. package/src/resolution/activation-need.ts +18 -14
  81. package/src/resolution/binding-lookup-cache.ts +18 -2
  82. package/src/resolution/binding-scope.ts +6 -17
  83. package/src/resolution/binding-select.ts +14 -35
  84. package/src/resolution/diagnostics.ts +2 -2
  85. package/src/resolution/environment.ts +181 -35
  86. package/src/resolution/instantiation-plan.ts +5 -5
  87. package/src/resolution/lifecycle.ts +55 -53
  88. package/src/resolution/resolution-path.ts +119 -25
  89. package/src/resolution/resolve-options.ts +51 -4
  90. package/src/resolution/resolver.ts +582 -785
  91. package/src/resolution/scope.ts +10 -0
@@ -1,7 +1,6 @@
1
- import type { Binding, BindingSlot } from "#/binding";
1
+ import type { Binding, ConstantBinding, DynamicAsyncBinding, DynamicBinding } from "#/binding";
2
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,37 +11,49 @@ import {
12
11
  NoMatchingBindingError,
13
12
  TokenNotBoundError,
14
13
  } from "#/errors";
15
- import type { MetadataReader } from "#/metadata/metadata-types";
14
+ import type { MetadataReader, ParamMetadata } from "#/metadata/metadata-types";
16
15
  import type { BindingRegistry } from "#/registry";
17
16
  import { ActivationNeedCache } from "#/resolution/activation-need";
18
17
  import type { DefaultLookupEntry } from "#/resolution/binding-lookup-cache";
19
18
  import { BindingLookupCache } from "#/resolution/binding-lookup-cache";
20
- import { selectAllBindings, selectBinding } from "#/resolution/binding-select";
19
+ import { matchesSlot, selectAllBindings, selectBinding } from "#/resolution/binding-select";
21
20
  import { ClassIntrospector } from "#/resolution/class-introspector";
22
21
  import type { ResolutionDiagnostics } from "#/resolution/diagnostics";
23
22
  import type { ResolverCallbacks } from "#/resolution/environment";
24
- import { buildResolutionFrame, DefaultResolutionContext } from "#/resolution/environment";
23
+ import {
24
+ AsyncCascadeContext,
25
+ AsyncLevelContext,
26
+ buildResolutionFrame,
27
+ DefaultResolutionContext,
28
+ } from "#/resolution/environment";
25
29
  import { InstantiationPlanCompiler, PLAN_RETRY } from "#/resolution/instantiation-plan";
26
30
  import type { LifecycleManager } from "#/resolution/lifecycle";
27
- import { enterResolutionPath, exitResolutionPath } from "#/resolution/resolution-path";
28
- 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";
29
42
  import type { ScopeManager } from "#/resolution/scope";
30
43
  import type { Token } from "#/token";
31
44
  import { tokenName } from "#/token";
32
45
  import type {
33
46
  ActivationHandler,
34
47
  BindingIdentifier,
35
- BindingScope,
36
- BindingTag,
37
48
  ConstraintContext,
38
49
  Constructor,
39
50
  ResolutionFrame,
40
51
  ResolveOptions,
41
52
  } from "#/types";
42
53
 
43
- type BindingWithScope = Binding & { scope: BindingScope };
44
54
  const EMPTY_STRING_LIST: ReadonlyArray<string> = [];
45
55
  const EMPTY_FRAME_LIST: ReadonlyArray<ResolutionFrame> = [];
56
+ const EMPTY_PARAM_LIST: ReadonlyArray<ParamMetadata> = [];
46
57
  const ROOT_CONSTRAINT_CONTEXT = {
47
58
  resolutionPath: EMPTY_STRING_LIST,
48
59
  resolutionStack: EMPTY_FRAME_LIST,
@@ -56,9 +67,22 @@ const ROOT_CONSTRAINT_CONTEXT = {
56
67
  */
57
68
  export class DependencyResolver implements ResolverCallbacks {
58
69
  readonly #syncResolutionContextPool: Array<DefaultResolutionContext> = [];
59
- // Pure allocation pool: a chain's own context is threaded through the call, so nothing here
60
- // identifies a chain.
61
- readonly #asyncChainContextPool: Array<DefaultResolutionContext> = [];
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;
62
86
  // Compiled plans; `null` marks a binding as unplannable under the current cache versions.
63
87
  readonly #classPlanByBindingId = new Map<BindingIdentifier, (() => unknown) | null>();
64
88
  #classPlanRegistryVersion = -1;
@@ -92,11 +116,11 @@ export class DependencyResolver implements ResolverCallbacks {
92
116
  parent === undefined ? undefined : parent.#lookup,
93
117
  );
94
118
  this.#classes = new ClassIntrospector(metadataReader, container);
95
- this.#activation = new ActivationNeedCache(lifecycle, this.#classes);
119
+ this.#activation = new ActivationNeedCache(lifecycle, this.#classes, registry);
96
120
  }
97
121
 
98
122
  /** Structural counts for {@link RESOLUTION_DIAGNOSTICS}; see `resolution/diagnostics.ts`. */
99
- describeCaches(): Pick<ResolutionDiagnostics, "asyncContextPoolSize" | "compiledPlanCount" | "syncContextPoolSize"> {
123
+ describeCaches(): Pick<ResolutionDiagnostics, "compiledPlanCount" | "syncContextPoolSize"> {
100
124
  let compiledPlanCount = 0;
101
125
  for (const plan of this.#classPlanByBindingId.values()) {
102
126
  if (plan !== null) {
@@ -104,7 +128,6 @@ export class DependencyResolver implements ResolverCallbacks {
104
128
  }
105
129
  }
106
130
  return {
107
- asyncContextPoolSize: this.#asyncChainContextPool.length,
108
131
  compiledPlanCount,
109
132
  syncContextPoolSize: this.#syncResolutionContextPool.length,
110
133
  };
@@ -117,15 +140,13 @@ export class DependencyResolver implements ResolverCallbacks {
117
140
  options: ResolveOptions | undefined,
118
141
  resolutionPath: Array<string>,
119
142
  resolutionStack: Array<ResolutionFrame>,
120
- ): { binding: Binding; owner: DependencyResolver } | undefined {
143
+ ): DefaultLookupEntry<DependencyResolver> | undefined {
121
144
  if (options === undefined) {
122
145
  const fastDefaultBinding = this.#registry.getFastDefault(token);
123
146
  if (fastDefaultBinding !== undefined) {
124
147
  return { binding: fastDefaultBinding, owner: this };
125
148
  }
126
- }
127
-
128
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
149
+ } else if (isNameOnlyOptions(options)) {
129
150
  const namedBinding = this.#registry.getSimpleNamed(token, options.name);
130
151
  if (
131
152
  namedBinding !== undefined &&
@@ -133,37 +154,33 @@ export class DependencyResolver implements ResolverCallbacks {
133
154
  ) {
134
155
  return { binding: namedBinding, owner: this };
135
156
  }
136
- }
137
-
138
- if (
139
- options !== undefined &&
140
- options.name === undefined &&
141
- options.tag === undefined &&
142
- (options.tags?.length ?? 0) === 1
143
- ) {
144
- const [tagKey, tagValue] = options.tags![0]!;
145
- const tagged = this.#registry.getSimpleTagged(token, tagKey, tagValue);
146
- if (tagged !== undefined) {
147
- 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
+ }
148
164
  }
149
165
  }
150
166
 
151
167
  const bindings = this.#registry.getAll(token);
152
168
  if (bindings.length > 0) {
153
- if (bindings.length === 1) {
154
- const onlyBinding = bindings[0]!;
155
- const isDefaultSlot = onlyBinding.slot.name === undefined && onlyBinding.slot.tags.length === 0;
156
- if (options === undefined && isDefaultSlot && onlyBinding.predicate === undefined) {
157
- return { binding: onlyBinding, owner: this };
158
- }
159
- if (this.#matchesBindingFast(onlyBinding, options, resolutionPath, resolutionStack)) {
160
- return { binding: onlyBinding, owner: this };
161
- }
162
- }
163
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
164
- const binding = selectBinding(bindings, options, ctx, this.#getTokenName(token));
165
- if (binding !== undefined) {
166
- 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 };
167
184
  }
168
185
  }
169
186
  if (this.#parent !== undefined) {
@@ -172,13 +189,55 @@ export class DependencyResolver implements ResolverCallbacks {
172
189
  return undefined;
173
190
  }
174
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
+
175
234
  /**
176
235
  * Binding lookup aligned with `resolve` — used by `Container.validate` without instantiating.
177
236
  */
178
237
  peekBindingForValidate(
179
238
  token: Token<unknown> | Constructor,
180
239
  options: ResolveOptions | undefined,
181
- ): { binding: Binding; owner: DependencyResolver } | undefined {
240
+ ): DefaultLookupEntry<DependencyResolver> | undefined {
182
241
  return this.#findBinding(token, options, [], []);
183
242
  }
184
243
 
@@ -188,16 +247,8 @@ export class DependencyResolver implements ResolverCallbacks {
188
247
  peekCandidateBindingsForValidate(
189
248
  token: Token<unknown> | Constructor,
190
249
  options: ResolveOptions | undefined,
191
- ): Array<Binding> {
192
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
193
- return this.#getSimpleNamedBindingsFromChain(token, options.name);
194
- }
195
- const allBindings = this.#getAllBindingsFromChain(token);
196
- if (allBindings.length === 0) {
197
- return [];
198
- }
199
- const ctx = this.#makeConstraintContext([], [], options);
200
- return selectAllBindings(allBindings, options, ctx);
250
+ ): ReadonlyArray<Binding> {
251
+ return this.#candidateBindings(token, options, [], []);
201
252
  }
202
253
 
203
254
  // ── Sync resolve ───────────────────────────────────────────────────────────
@@ -211,85 +262,77 @@ export class DependencyResolver implements ResolverCallbacks {
211
262
  // (parent-chain walk + alias folding) only on miss or alias.
212
263
  const fastBinding = this.#registry.getFastDefault(token);
213
264
  if (fastBinding !== undefined && fastBinding.kind !== "alias") {
214
- return this.#resolveDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack);
265
+ return this.#resolveDefaultEntry(fastBinding, this, resolutionPath, resolutionStack) as Value;
215
266
  }
216
267
  const entry = this.#lookup.defaultEntry(token);
217
268
  if (entry === null) {
218
269
  return this.resolve(token, undefined, resolutionPath, resolutionStack);
219
270
  }
220
- return this.#resolveDefaultEntry<Value>(entry.binding, entry.owner, resolutionPath, resolutionStack);
271
+ return this.#resolveDefaultEntry(entry.binding, entry.owner, resolutionPath, resolutionStack) as Value;
221
272
  }
222
273
 
223
- #resolveDefaultEntry<const Value>(
274
+ #resolveDefaultEntry(
224
275
  binding: Binding,
225
276
  owner: DependencyResolver,
226
277
  resolutionPath: Array<string>,
227
278
  resolutionStack: Array<ResolutionFrame>,
228
- ): Value {
229
- if (
230
- binding.kind === "constant" &&
231
- binding.onActivation === undefined &&
232
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
233
- ) {
234
- return binding.value as Value;
235
- }
236
- const scope = (binding as BindingWithScope).scope ?? "transient";
279
+ ): unknown {
280
+ const scope = binding.scope;
237
281
  if (scope === "transient") {
238
282
  if (binding.kind === "dynamic") {
239
283
  const containerHooks =
240
284
  this.#lifecycle.activationVersion === 0 ? undefined : this.#lifecycle.activationHandlersFor(binding.token);
241
285
  if (binding.onActivation === undefined && (containerHooks === undefined || containerHooks.length === 0)) {
242
- return this.#resolveTransientDynamicSyncFromContext(
243
- binding as Binding<Value> & { kind: "dynamic" },
244
- resolutionPath,
245
- resolutionStack,
246
- );
286
+ return this.#resolveTransientDynamicSyncFromContext(binding, resolutionPath, resolutionStack);
247
287
  }
248
- return this.#resolveTransientDynamicActivatedSync(
249
- binding as Binding<Value> & { kind: "dynamic" },
250
- containerHooks,
251
- resolutionPath,
252
- resolutionStack,
253
- );
288
+ return this.#resolveTransientDynamicActivatedSync(binding, containerHooks, resolutionPath, resolutionStack);
254
289
  }
255
290
  // Compiled plans only run at the top level — inner levels keep the runtime cycle guard.
256
291
  if ((binding.kind === "class" || binding.kind === "resolved") && resolutionPath.length === 0) {
257
292
  const plan = this.#getInstantiationPlan(binding);
258
293
  if (plan !== null) {
259
- return plan() as Value;
294
+ return plan();
260
295
  }
261
296
  }
262
297
  } else if (scope === "singleton") {
298
+ // A constant is a singleton that is already its own instance.
299
+ if (this.#isPlainConstant(binding)) {
300
+ return binding.value;
301
+ }
263
302
  const cachedSingleton = binding.instance;
264
303
  if (cachedSingleton !== NO_INSTANCE) {
265
- return cachedSingleton as Value;
304
+ return cachedSingleton;
266
305
  }
267
306
  if (owner !== this) {
268
- return owner.#resolveBinding(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
307
+ return owner.#resolveBinding(binding, undefined, resolutionPath, resolutionStack);
269
308
  }
270
309
  } else {
271
- if (!this.#scope.isChild) {
272
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
273
- }
274
- if (this.#scope.hasScoped(binding.id)) {
275
- return this.#scope.getScoped<Value>(binding.id);
310
+ const cachedScoped = this.#readScoped(binding);
311
+ if (cachedScoped !== SCOPED_MISS) {
312
+ return cachedScoped;
276
313
  }
277
314
  }
278
- return this.#resolveBinding(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
315
+ return this.#resolveBinding(binding, undefined, resolutionPath, resolutionStack);
279
316
  }
280
317
 
281
318
  // Lean lane for an activated transient dynamic binding: same observable behavior as the
282
319
  // generic #resolveBinding path (guard, frame, ctx, per-binding then container hooks) with
283
320
  // the kind/activation dispatch resolved statically.
284
- #resolveTransientDynamicActivatedSync<const Value>(
285
- binding: Binding<Value> & { kind: "dynamic" },
321
+ #resolveTransientDynamicActivatedSync(
322
+ binding: DynamicBinding<unknown>,
286
323
  containerHooks: ReadonlyArray<ActivationHandler<unknown>> | undefined,
287
324
  resolutionPath: Array<string>,
288
325
  resolutionStack: Array<ResolutionFrame>,
289
- ): 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.
290
329
  const frame = this.#getResolutionFrame(binding);
291
330
  const tokenDisplayName = frame.tokenName;
292
- 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);
293
336
  resolutionStack.push(frame);
294
337
  try {
295
338
  const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, undefined);
@@ -311,14 +354,14 @@ export class DependencyResolver implements ResolverCallbacks {
311
354
  if (activationResult instanceof Promise) {
312
355
  throw new AsyncActivationError(tokenDisplayName, "onActivation");
313
356
  }
314
- activated = activationResult as Value;
357
+ activated = activationResult;
315
358
  }
316
359
  }
317
360
  return activated;
318
361
  } finally {
319
362
  resolutionStack.pop();
320
363
  resolutionPath.pop();
321
- resolutionSet?.delete(tokenDisplayName);
364
+ binding.inFlight = false;
322
365
  }
323
366
  }
324
367
 
@@ -343,7 +386,7 @@ export class DependencyResolver implements ResolverCallbacks {
343
386
  return compiled;
344
387
  }
345
388
 
346
- // 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.
347
390
  readonly #planCompiler = new InstantiationPlanCompiler({
348
391
  hasActivationHandlers: (token) => this.#lifecycle.hasActivationHandlers(token),
349
392
  knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
@@ -354,7 +397,7 @@ export class DependencyResolver implements ResolverCallbacks {
354
397
  return entry === null ? null : { binding: entry.binding };
355
398
  },
356
399
  getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
357
- // Dispatches exactly as #resolveClassDeps does, so an escaped dep is indistinguishable
400
+ // Dispatches exactly as #resolveDep does, so an escaped dep is indistinguishable
358
401
  // from the same dep on a fully interpreted resolve.
359
402
  resolveEscaped: (token, options, arity, resolutionPath, resolutionStack) => {
360
403
  if (arity === "all") {
@@ -378,139 +421,74 @@ export class DependencyResolver implements ResolverCallbacks {
378
421
  ): Value {
379
422
  // Name-only fast lane: memoized lookup, dispatching just the shapes whose
380
423
  // semantics involve no resolution context (constants, cached singletons).
381
- if (
382
- options !== undefined &&
383
- options.name !== undefined &&
384
- options.tag === undefined &&
385
- (options.tags === undefined || options.tags.length === 0)
386
- ) {
424
+ if (options !== undefined && isNameOnlyOptions(options)) {
387
425
  const namedEntry = this.#lookup.namedEntry(token, options.name);
388
426
  if (namedEntry !== null) {
389
427
  const namedBinding = namedEntry.binding;
390
- if (
391
- namedBinding.kind === "constant" &&
392
- namedBinding.onActivation === undefined &&
393
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(namedBinding.token))
394
- ) {
428
+ if (this.#isPlainConstant(namedBinding)) {
395
429
  return namedBinding.value as Value;
396
430
  }
397
- const namedScope = (namedBinding as BindingWithScope).scope ?? "transient";
398
- if (namedScope === "singleton") {
399
- if (namedBinding.instance !== NO_INSTANCE) {
400
- return namedBinding.instance as Value;
401
- }
431
+ if (namedBinding.scope === "singleton" && namedBinding.instance !== NO_INSTANCE) {
432
+ return namedBinding.instance as Value;
402
433
  }
403
434
  // Everything else keeps the full path (context, activation, guards).
404
435
  }
405
436
  }
406
437
 
407
- let currentToken: Token<unknown> | Constructor = token;
408
- let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
409
- let found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
410
-
411
- // Follow aliases iteratively with exact cycle detection — a revisited alias
412
- // token throws CircularDependencyError instead of overflowing the call stack.
413
- while (found !== undefined && found.binding.kind === "alias") {
414
- const target = found.binding.target;
415
- visitedAliasTokens ??= new Set([currentToken]);
416
- if (visitedAliasTokens.has(target)) {
417
- throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
418
- }
419
- visitedAliasTokens.add(target);
420
- currentToken = target;
421
- found = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
422
- }
423
-
424
- if (found === undefined) {
425
- const ownBindings = this.#registry.getAll(currentToken);
426
- if (ownBindings.length > 0) {
427
- throw new NoMatchingBindingError(
428
- this.#getTokenName(currentToken),
429
- options ?? {},
430
- this.#getAvailableSlots(currentToken),
431
- );
432
- }
433
- throw new TokenNotBoundError(this.#getTokenName(currentToken));
434
- }
435
-
436
- const { binding, owner } = found;
438
+ const { binding, owner } = this.#requireBinding(token, options, resolutionPath, resolutionStack);
437
439
 
438
- const scope = (binding as BindingWithScope).scope ?? "transient";
439
-
440
- // Singleton from a parent resolver: delegate so the parent caches it correctly
441
- if (scope === "singleton" && owner !== this) {
442
- return owner.#resolveBinding(binding as Binding<Value>, 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;
443
443
  }
444
-
445
- // Scoped/transient (or own singleton): resolve with this resolver's container/scope
446
- return this.#resolveBinding(binding as Binding<Value>, options, resolutionPath, resolutionStack);
444
+ return this.#resolveBinding(binding, options, resolutionPath, resolutionStack) as Value;
447
445
  }
448
446
 
449
- #resolveBinding<const Value>(
450
- binding: Binding<Value>,
447
+ #resolveBinding(
448
+ binding: Binding,
451
449
  options: ResolveOptions | undefined,
452
450
  resolutionPath: Array<string>,
453
451
  resolutionStack: Array<ResolutionFrame>,
454
- ): Value {
455
- if (
456
- binding.kind === "constant" &&
457
- binding.onActivation === undefined &&
458
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
459
- ) {
452
+ ): unknown {
453
+ if (this.#isPlainConstant(binding)) {
460
454
  return binding.value;
461
455
  }
462
456
 
463
- const scope = (binding as BindingWithScope).scope ?? "transient";
464
-
465
- // Singleton cache check
466
- if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
467
- return binding.instance as Value;
468
- }
469
-
470
- // Scoped cache check
471
- if (scope === "scoped") {
472
- if (!this.#scope.isChild) {
473
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
457
+ const scope = binding.scope;
458
+ if (scope === "singleton") {
459
+ if (binding.instance !== NO_INSTANCE) {
460
+ return binding.instance;
474
461
  }
475
- if (this.#scope.hasScoped(binding.id)) {
476
- 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;
477
466
  }
478
467
  }
479
468
 
480
469
  const frame = this.#getResolutionFrame(binding);
481
470
  const tokenDisplayName = frame.tokenName;
482
- const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName, false);
471
+ const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName);
483
472
  resolutionStack.push(frame);
484
- const needsActivation = this.#activation.needsActivation(binding);
485
- if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
486
- const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options);
487
- 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);
488
477
  const dynamicResult = binding.factory(resolutionCtx);
489
478
  if (dynamicResult instanceof Promise) {
490
- throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
479
+ throw new AsyncResolutionError(tokenDisplayName, tokenDisplayName);
491
480
  }
492
- resolutionStack.pop();
493
- resolutionPath.pop();
494
- resolutionSet?.delete(tokenDisplayName);
495
481
  return dynamicResult;
496
- } catch (error) {
497
- resolutionStack.pop();
498
- resolutionPath.pop();
499
- resolutionSet?.delete(tokenDisplayName);
500
- throw error;
501
482
  }
502
- }
503
483
 
504
- try {
505
- const needsResolutionContext = needsActivation || this.#requiresResolutionContext(binding);
506
- const resolutionCtx = needsResolutionContext
507
- ? this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options)
508
- : undefined;
484
+ const resolutionCtx =
485
+ needsActivation || requiresResolutionContext(binding)
486
+ ? this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options)
487
+ : undefined;
509
488
 
510
489
  const instance = this.#instantiateSync(binding, resolutionCtx, resolutionPath, resolutionStack);
511
490
 
512
- const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
513
- const activated = shouldActivate
491
+ const activated = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation)
514
492
  ? this.#lifecycle.runActivationSync(
515
493
  resolutionCtx as DefaultResolutionContext,
516
494
  binding,
@@ -519,7 +497,6 @@ export class DependencyResolver implements ResolverCallbacks {
519
497
  )
520
498
  : instance;
521
499
 
522
- // Cache by scope
523
500
  if (scope === "singleton") {
524
501
  this.#scope.setSingleton(binding, activated);
525
502
  } else if (scope === "scoped") {
@@ -534,12 +511,12 @@ export class DependencyResolver implements ResolverCallbacks {
534
511
  }
535
512
  }
536
513
 
537
- #instantiateSync<const Value>(
538
- binding: Binding<Value>,
514
+ #instantiateSync(
515
+ binding: Binding,
539
516
  ctx: DefaultResolutionContext | undefined,
540
517
  resolutionPath: Array<string>,
541
518
  resolutionStack: Array<ResolutionFrame>,
542
- ): Value {
519
+ ): unknown {
543
520
  switch (binding.kind) {
544
521
  case "constant":
545
522
  return binding.value;
@@ -559,13 +536,12 @@ export class DependencyResolver implements ResolverCallbacks {
559
536
  throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
560
537
 
561
538
  case "class": {
562
- const deps = this.#resolveClassDeps(binding.target, resolutionPath, resolutionStack);
563
- const instance = this.#classes.instantiate(binding.target, deps);
564
- return instance as Value;
539
+ const deps = this.#resolveDeps(this.#constructorParams(binding.target), resolutionPath, resolutionStack);
540
+ return this.#classes.instantiate(binding.target, deps);
565
541
  }
566
542
 
567
543
  case "resolved": {
568
- const deps = this.#resolveDescriptorDeps(binding.deps, resolutionPath, resolutionStack);
544
+ const deps = this.#resolveDeps(binding.deps, resolutionPath, resolutionStack);
569
545
  const factoryResult = binding.factory(...deps);
570
546
  if (factoryResult instanceof Promise) {
571
547
  throw new AsyncResolutionError(tokenName(binding.token), tokenName(binding.token));
@@ -581,90 +557,58 @@ export class DependencyResolver implements ResolverCallbacks {
581
557
  }
582
558
  }
583
559
 
584
- #resolveClassDeps(
585
- target: Constructor,
586
- resolutionPath: Array<string>,
587
- resolutionStack: Array<ResolutionFrame>,
588
- ): Array<unknown> {
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> {
589
567
  const meta = this.#classes.constructorMetadata(target);
590
- if (meta === undefined) {
591
- if (target.length === 0) {
592
- return [];
593
- }
594
- throw new MissingMetadataError(target.name);
568
+ if (meta !== undefined) {
569
+ return meta.params;
595
570
  }
596
- if (meta.params.length === 0) {
597
- return [];
598
- }
599
- if (meta.params.length === 1) {
600
- const param = meta.params[0]!;
601
- const paramOptions = injectionSlotToResolveOptions(param);
602
- if (param.multi) {
603
- return [this.resolveAll(param.token, paramOptions, resolutionPath, resolutionStack)];
604
- }
605
- if (param.optional) {
606
- return [this.resolveOptional(param.token, paramOptions, resolutionPath, resolutionStack)];
607
- }
608
- if (paramOptions === undefined) {
609
- return [this.resolveFromContext(param.token, resolutionPath, resolutionStack)];
610
- }
611
- return [this.resolve(param.token, paramOptions, resolutionPath, resolutionStack)];
612
- }
613
- const deps = new Array<unknown>(meta.params.length);
614
- for (let index = 0; index < meta.params.length; index += 1) {
615
- const param = meta.params[index]!;
616
- const paramOptions = injectionSlotToResolveOptions(param);
617
- if (param.multi) {
618
- deps[index] = this.resolveAll(param.token, paramOptions, resolutionPath, resolutionStack);
619
- continue;
620
- }
621
- if (param.optional) {
622
- deps[index] = this.resolveOptional(param.token, paramOptions, resolutionPath, resolutionStack);
623
- continue;
624
- }
625
- deps[index] =
626
- paramOptions === undefined
627
- ? this.resolveFromContext(param.token, resolutionPath, resolutionStack)
628
- : this.resolve(param.token, paramOptions, resolutionPath, resolutionStack);
571
+ if (target.length === 0) {
572
+ return EMPTY_PARAM_LIST;
629
573
  }
630
- return deps;
574
+ throw new MissingMetadataError(target.name);
631
575
  }
632
576
 
633
- #resolveDescriptorDeps(
634
- 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>,
635
581
  resolutionPath: Array<string>,
636
582
  resolutionStack: Array<ResolutionFrame>,
637
583
  ): Array<unknown> {
638
- const resolved = new Array<unknown>(deps.length);
639
- for (let index = 0; index < deps.length; index += 1) {
640
- const dep = deps[index]!;
641
- const depOptions = injectionSlotToResolveOptions(dep);
642
- if (dep.multi) {
643
- resolved[index] = this.resolveAll(
644
- dep.token as Token<unknown> | Constructor,
645
- depOptions,
646
- resolutionPath,
647
- resolutionStack,
648
- );
649
- continue;
650
- }
651
- if (dep.optional) {
652
- resolved[index] = this.resolveOptional(
653
- dep.token as Token<unknown> | Constructor,
654
- depOptions,
655
- resolutionPath,
656
- resolutionStack,
657
- );
658
- continue;
659
- }
660
- resolved[index] =
661
- depOptions === undefined
662
- ? this.resolveFromContext(dep.token as Token<unknown> | Constructor, resolutionPath, resolutionStack)
663
- : 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);
664
594
  }
665
595
  return resolved;
666
596
  }
667
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
+
668
612
  resolveOptional<const Value>(
669
613
  token: Token<Value> | Constructor<Value>,
670
614
  options: ResolveOptions | undefined,
@@ -683,50 +627,53 @@ export class DependencyResolver implements ResolverCallbacks {
683
627
  resolutionPath: Array<string>,
684
628
  resolutionStack: Array<ResolutionFrame>,
685
629
  ): Array<Value> {
686
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
687
- const namedCandidates = this.#getSimpleNamedBindingsFromChain(token, options.name);
688
- if (namedCandidates.length === 0) {
689
- return [];
690
- }
691
- const resolved = new Array<Value>(namedCandidates.length);
692
- for (let index = 0; index < namedCandidates.length; index += 1) {
693
- resolved[index] = this.#resolveCandidateSync(
694
- namedCandidates[index] as Binding<Value>,
695
- options,
696
- resolutionPath,
697
- resolutionStack,
698
- );
699
- }
700
- return resolved;
701
- }
702
-
703
- const allBindings = this.#getAllBindingsFromChain(token);
704
- if (allBindings.length === 0) {
705
- return [];
706
- }
707
-
708
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
709
- const candidates = selectAllBindings(allBindings, options, ctx);
710
-
630
+ const candidates = this.#candidateBindings(token, options, resolutionPath, resolutionStack);
711
631
  const resolved = new Array<Value>(candidates.length);
712
632
  for (let index = 0; index < candidates.length; index += 1) {
713
633
  resolved[index] = this.#resolveCandidateSync(
714
- candidates[index] as Binding<Value>,
634
+ candidates[index]!,
715
635
  options,
716
636
  resolutionPath,
717
637
  resolutionStack,
718
- );
638
+ ) as Value;
719
639
  }
720
640
  return resolved;
721
641
  }
722
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
+
723
670
  // ── Async resolve ──────────────────────────────────────────────────────────
724
671
 
725
672
  resolveAsyncFromContext<const Value>(
726
673
  token: Token<Value> | Constructor<Value>,
727
674
  resolutionPath: Array<string>,
728
675
  resolutionStack: Array<ResolutionFrame>,
729
- callerContext?: DefaultResolutionContext,
676
+ branchDepth: BranchDepth,
730
677
  ): Promise<Value> {
731
678
  // Hot lane: own-registry fast default (async chains resolve sibling dynamic bindings).
732
679
  // Fall back to the chain-versioned memo only on miss or alias.
@@ -736,75 +683,67 @@ export class DependencyResolver implements ResolverCallbacks {
736
683
  if (
737
684
  (fastBinding.kind === "dynamic-async" || fastBinding.kind === "dynamic") &&
738
685
  fastBinding.scope === "transient" &&
739
- fastBinding.onActivation === undefined &&
740
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(fastBinding.token))
686
+ !this.#hasAnyActivation(fastBinding)
741
687
  ) {
742
688
  return this.#resolveTransientDynamicAsyncFromContext(
743
- fastBinding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
689
+ fastBinding,
744
690
  resolutionPath,
745
691
  resolutionStack,
746
- callerContext,
747
- );
692
+ branchDepth,
693
+ ) as Promise<Value>;
748
694
  }
749
- return this.#resolveAsyncDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack, callerContext);
695
+ return this.#resolveAsyncDefaultEntry(
696
+ fastBinding,
697
+ this,
698
+ resolutionPath,
699
+ resolutionStack,
700
+ branchDepth,
701
+ ) as Promise<Value>;
750
702
  }
751
703
  const entry = this.#lookup.defaultEntry(token);
752
704
  if (entry === null) {
753
- return this.resolveAsync(token, undefined, resolutionPath, resolutionStack);
705
+ return this.resolveAsync(token, undefined, resolutionPath, resolutionStack, branchDepth);
754
706
  }
755
- return this.#resolveAsyncDefaultEntry<Value>(
707
+ return this.#resolveAsyncDefaultEntry(
756
708
  entry.binding,
757
709
  entry.owner,
758
710
  resolutionPath,
759
711
  resolutionStack,
760
- callerContext,
761
- );
712
+ branchDepth,
713
+ ) as Promise<Value>;
762
714
  }
763
715
 
764
- #resolveAsyncDefaultEntry<const Value>(
716
+ #resolveAsyncDefaultEntry(
765
717
  binding: Binding,
766
718
  owner: DependencyResolver,
767
719
  resolutionPath: Array<string>,
768
720
  resolutionStack: Array<ResolutionFrame>,
769
- callerContext?: DefaultResolutionContext,
770
- ): Promise<Value> {
771
- if (
772
- binding.kind === "constant" &&
773
- binding.onActivation === undefined &&
774
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
775
- ) {
776
- return Promise.resolve(binding.value as Value);
721
+ branchDepth: BranchDepth,
722
+ ): Promise<unknown> {
723
+ if (this.#isPlainConstant(binding)) {
724
+ return Promise.resolve(binding.value);
777
725
  }
778
- const scope = (binding as BindingWithScope).scope ?? "transient";
726
+ const scope = binding.scope;
779
727
  if (scope === "transient") {
780
- if (
781
- (binding.kind === "dynamic" || binding.kind === "dynamic-async") &&
782
- binding.onActivation === undefined &&
783
- (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token))
784
- ) {
785
- return this.#resolveTransientDynamicAsyncFromContext(
786
- binding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
787
- resolutionPath,
788
- resolutionStack,
789
- callerContext,
790
- );
728
+ if ((binding.kind === "dynamic" || binding.kind === "dynamic-async") && !this.#hasAnyActivation(binding)) {
729
+ return this.#resolveTransientDynamicAsyncFromContext(binding, resolutionPath, resolutionStack, branchDepth);
791
730
  }
792
731
  } else if (scope === "singleton") {
793
732
  if (binding.instance !== NO_INSTANCE) {
794
- return Promise.resolve(binding.instance as Value);
733
+ return Promise.resolve(binding.instance);
795
734
  }
796
735
  if (owner !== this) {
797
- return owner.#resolveBindingAsync(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
798
- }
799
- } else {
800
- if (!this.#scope.isChild) {
801
- return Promise.reject(new MissingScopeContextError(this.#getTokenName(binding.token)));
736
+ return owner.#resolveBindingAsync(binding, undefined, resolutionPath, resolutionStack, branchDepth);
802
737
  }
738
+ } else if (this.#scope.isChild) {
803
739
  if (this.#scope.hasScoped(binding.id)) {
804
- return Promise.resolve(this.#scope.getScoped<Value>(binding.id));
740
+ return Promise.resolve(this.#scope.getScoped(binding.id));
805
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)));
806
745
  }
807
- return this.#resolveBindingAsync(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
746
+ return this.#resolveBindingAsync(binding, undefined, resolutionPath, resolutionStack, branchDepth);
808
747
  }
809
748
 
810
749
  async resolveAsync<const Value>(
@@ -812,174 +751,125 @@ export class DependencyResolver implements ResolverCallbacks {
812
751
  options: ResolveOptions | undefined,
813
752
  resolutionPath: Array<string>,
814
753
  resolutionStack: Array<ResolutionFrame>,
754
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
815
755
  ): Promise<Value> {
816
- const found = this.#findBinding(token, options, resolutionPath, resolutionStack);
817
-
818
- if (found === undefined) {
819
- const ownBindings = this.#registry.getAll(token);
820
- if (ownBindings.length > 0) {
821
- throw new NoMatchingBindingError(this.#getTokenName(token), options ?? {}, this.#getAvailableSlots(token));
822
- }
823
- throw new TokenNotBoundError(this.#getTokenName(token));
824
- }
825
-
826
- let currentToken: Token<unknown> | Constructor = token;
827
- let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
828
- let aliasFollowed: DefaultLookupEntry<DependencyResolver> | undefined = found;
829
- while (aliasFollowed !== undefined && aliasFollowed.binding.kind === "alias") {
830
- const target = aliasFollowed.binding.target;
831
- visitedAliasTokens ??= new Set([currentToken]);
832
- if (visitedAliasTokens.has(target)) {
833
- throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
834
- }
835
- visitedAliasTokens.add(target);
836
- currentToken = target;
837
- aliasFollowed = this.#findBinding(currentToken, options, resolutionPath, resolutionStack);
838
- }
839
- if (aliasFollowed === undefined) {
840
- const ownBindings = this.#registry.getAll(currentToken);
841
- if (ownBindings.length > 0) {
842
- throw new NoMatchingBindingError(
843
- this.#getTokenName(currentToken),
844
- options ?? {},
845
- this.#getAvailableSlots(currentToken),
846
- );
847
- }
848
- throw new TokenNotBoundError(this.#getTokenName(currentToken));
849
- }
850
- const { binding, owner } = aliasFollowed;
851
-
852
- const scope = (binding as BindingWithScope).scope ?? "transient";
756
+ const { binding, owner } = this.#requireBinding(token, options, resolutionPath, resolutionStack);
853
757
 
854
- if (scope === "singleton" && owner !== this) {
855
- 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;
856
760
  }
857
-
858
- return this.#resolveBindingAsync(binding as Binding<Value>, options, resolutionPath, resolutionStack);
761
+ return this.#resolveBindingAsync(binding, options, resolutionPath, resolutionStack, branchDepth) as Value;
859
762
  }
860
763
 
861
- async #resolveBindingAsync<const Value>(
862
- binding: Binding<Value>,
764
+ async #resolveBindingAsync(
765
+ binding: Binding,
863
766
  options: ResolveOptions | undefined,
864
767
  resolutionPath: Array<string>,
865
768
  resolutionStack: Array<ResolutionFrame>,
866
- ): Promise<Value> {
867
- if (
868
- binding.kind === "constant" &&
869
- binding.onActivation === undefined &&
870
- !this.#lifecycle.hasActivationHandlers(binding.token)
871
- ) {
769
+ branchDepth: BranchDepth,
770
+ ): Promise<unknown> {
771
+ if (this.#isPlainConstant(binding)) {
872
772
  return binding.value;
873
773
  }
874
774
 
875
- const scope = (binding as BindingWithScope).scope ?? "transient";
876
-
877
- // Singleton cache
775
+ const scope = binding.scope;
878
776
  if (scope === "singleton") {
879
777
  if (binding.instance !== NO_INSTANCE) {
880
- return binding.instance as Value;
778
+ return binding.instance;
881
779
  }
882
- // In-flight dedup
780
+ // In-flight dedup: concurrent callers share the first creation.
883
781
  const inflight = this.#scope.getInflight(binding.id);
884
782
  if (inflight !== undefined) {
885
- return inflight as Promise<Value>;
886
- }
887
- }
888
-
889
- // Scoped cache
890
- if (scope === "scoped") {
891
- if (!this.#scope.isChild) {
892
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
783
+ return inflight;
893
784
  }
894
- if (this.#scope.hasScoped(binding.id)) {
895
- 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;
896
789
  }
897
790
  }
898
791
 
899
792
  const frame = this.#getResolutionFrame(binding);
900
- const frameName = frame.tokenName;
901
- const resolutionSet = enterResolutionPath(resolutionPath, frameName, false);
902
- resolutionStack.push(frame);
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
+
903
798
  const needsActivation = this.#activation.needsActivation(binding);
904
799
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
905
- const resolutionCtx = new DefaultResolutionContext(this, resolutionPath, resolutionStack, options);
906
- try {
907
- if (binding.kind === "dynamic-async") {
908
- return await binding.factory(resolutionCtx);
909
- }
910
- const dynamicResult = binding.factory(resolutionCtx);
911
- return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
912
- } finally {
913
- resolutionStack.pop();
914
- resolutionPath.pop();
915
- resolutionSet?.delete(frameName);
800
+ const resolutionCtx = new AsyncLevelContext(this, levelPath, levelStack, options);
801
+ if (binding.kind === "dynamic-async") {
802
+ return await binding.factory(resolutionCtx);
916
803
  }
804
+ const dynamicResult = binding.factory(resolutionCtx);
805
+ return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
917
806
  }
918
807
 
919
- const needsResolutionContext = needsActivation || this.#requiresResolutionContext(binding);
920
- const resolutionCtx = needsResolutionContext
921
- ? new DefaultResolutionContext(this, resolutionPath, resolutionStack, options)
922
- : undefined;
923
-
924
- try {
925
- if (scope === "singleton") {
926
- const createSingletonPromise = async (): Promise<Value> => {
927
- const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
928
-
929
- const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
930
- const activated = shouldActivate
931
- ? await this.#lifecycle.runActivation(
932
- resolutionCtx as DefaultResolutionContext,
933
- binding,
934
- instance,
935
- this.#metadataReader,
936
- )
937
- : instance;
808
+ const resolutionCtx =
809
+ needsActivation || requiresResolutionContext(binding)
810
+ ? new AsyncLevelContext(this, levelPath, levelStack, options)
811
+ : undefined;
938
812
 
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) => {
939
824
  this.#scope.setSingleton(binding, activated);
940
- binding.instance = activated;
941
825
  this.#scope.clearInflight(binding.id);
942
826
  return activated;
943
- };
944
-
945
- const singletonPromise = createSingletonPromise().catch((err: unknown) => {
827
+ },
828
+ (error: unknown) => {
946
829
  this.#scope.clearInflight(binding.id);
947
- throw err;
948
- });
949
- this.#scope.setInflight(binding.id, singletonPromise as Promise<unknown>);
950
- return await singletonPromise;
951
- }
952
-
953
- const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
954
-
955
- const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
956
- const activated = shouldActivate
957
- ? await this.#lifecycle.runActivation(
958
- resolutionCtx as DefaultResolutionContext,
959
- binding,
960
- instance,
961
- this.#metadataReader,
962
- )
963
- : instance;
964
-
965
- if (scope === "scoped") {
966
- this.#scope.setScoped(binding.id, activated);
967
- }
830
+ throw error;
831
+ },
832
+ );
833
+ this.#scope.setInflight(binding.id, singletonPromise as Promise<unknown>);
834
+ return await singletonPromise;
835
+ }
968
836
 
969
- return activated;
970
- } finally {
971
- resolutionStack.pop();
972
- resolutionPath.pop();
973
- 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);
974
847
  }
848
+ return activated;
975
849
  }
976
850
 
977
- async #instantiateAsync<const Value>(
978
- binding: Binding<Value>,
979
- ctx: DefaultResolutionContext | undefined,
851
+ async #instantiateAndActivateAsync(
852
+ binding: Binding,
853
+ ctx: AsyncLevelContext | undefined,
980
854
  resolutionPath: Array<string>,
981
855
  resolutionStack: Array<ResolutionFrame>,
982
- ): 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> {
983
873
  switch (binding.kind) {
984
874
  case "constant":
985
875
  return binding.value;
@@ -999,22 +889,26 @@ export class DependencyResolver implements ResolverCallbacks {
999
889
  return binding.factory(ctx);
1000
890
 
1001
891
  case "class": {
1002
- const deps = await this.#resolveClassDepsAsync(binding.target, resolutionPath, resolutionStack);
1003
- const instance = this.#classes.instantiate(binding.target, deps);
1004
- 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);
1005
899
  }
1006
900
 
1007
901
  case "resolved": {
1008
902
  if (ctx === undefined) {
1009
903
  throw new InternalError("resolved binding requires resolution context");
1010
904
  }
1011
- const deps = await this.#resolveDescriptorDepsAsync(binding.deps, resolutionPath, resolutionStack);
905
+ const deps = await this.#resolveDepsAsync(binding.deps, resolutionPath, resolutionStack, branchDepth);
1012
906
  const factoryResult = binding.factory(...deps);
1013
907
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1014
908
  }
1015
909
 
1016
910
  case "resolved-async": {
1017
- const deps = await this.#resolveDescriptorDepsAsync(binding.deps, resolutionPath, resolutionStack);
911
+ const deps = await this.#resolveDepsAsync(binding.deps, resolutionPath, resolutionStack, branchDepth);
1018
912
  return binding.factory(...deps);
1019
913
  }
1020
914
 
@@ -1023,114 +917,45 @@ export class DependencyResolver implements ResolverCallbacks {
1023
917
  }
1024
918
  }
1025
919
 
1026
- async #resolveClassDepsAsync(
1027
- target: Constructor,
920
+ async #resolveDepsAsync(
921
+ deps: ReadonlyArray<DependencySlot>,
1028
922
  resolutionPath: Array<string>,
1029
923
  resolutionStack: Array<ResolutionFrame>,
924
+ branchDepth: BranchDepth,
1030
925
  ): Promise<Array<unknown>> {
1031
- const meta = this.#classes.constructorMetadata(target);
1032
- if (meta === undefined) {
1033
- if (target.length === 0) {
1034
- return [];
1035
- }
1036
- throw new MissingMetadataError(target.name);
1037
- }
1038
- if (meta.params.length === 0) {
926
+ const count = deps.length;
927
+ if (count === 0) {
1039
928
  return [];
1040
929
  }
1041
- if (meta.params.length === 1) {
1042
- const param = meta.params[0]!;
1043
- const paramOptions = injectionSlotToResolveOptions(param);
1044
- if (param.multi) {
1045
- return [await this.resolveAllAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1046
- }
1047
- if (param.optional) {
1048
- return [await this.resolveOptionalAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1049
- }
1050
- if (paramOptions === undefined) {
1051
- return [await this.resolveAsyncFromContext(param.token, resolutionPath, resolutionStack)];
1052
- }
1053
- return [await this.resolveAsync(param.token, paramOptions, resolutionPath, resolutionStack)];
1054
- }
1055
- const pending = new Array<Promise<unknown>>(meta.params.length);
1056
- const shouldCloneContext = meta.params.length > 1;
1057
- for (let index = 0; index < meta.params.length; index += 1) {
1058
- const param = meta.params[index]!;
1059
- const paramOptions = injectionSlotToResolveOptions(param);
1060
- if (param.multi) {
1061
- pending[index] = this.resolveAllAsync(
1062
- param.token,
1063
- paramOptions,
1064
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1065
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1066
- );
1067
- } else if (param.optional) {
1068
- pending[index] = this.resolveOptionalAsync(
1069
- param.token,
1070
- paramOptions,
1071
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1072
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1073
- );
1074
- } else {
1075
- pending[index] =
1076
- paramOptions === undefined
1077
- ? this.resolveAsyncFromContext(
1078
- param.token,
1079
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1080
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1081
- )
1082
- : this.resolveAsync(
1083
- param.token,
1084
- paramOptions,
1085
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1086
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1087
- );
1088
- }
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);
1089
938
  }
1090
939
  return Promise.all(pending);
1091
940
  }
1092
941
 
1093
- async #resolveDescriptorDepsAsync(
1094
- deps: ReadonlyArray<InjectionDescriptor>,
942
+ #resolveDepAsync(
943
+ dep: DependencySlot,
1095
944
  resolutionPath: Array<string>,
1096
945
  resolutionStack: Array<ResolutionFrame>,
1097
- ): Promise<Array<unknown>> {
1098
- const pending = new Array<Promise<unknown>>(deps.length);
1099
- const shouldCloneContext = deps.length > 1;
1100
- for (let index = 0; index < deps.length; index += 1) {
1101
- const dep = deps[index]!;
1102
- const depOptions = injectionSlotToResolveOptions(dep);
1103
- if (dep.multi) {
1104
- pending[index] = this.resolveAllAsync(
1105
- dep.token as Token<unknown> | Constructor,
1106
- depOptions,
1107
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1108
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1109
- );
1110
- } else if (dep.optional) {
1111
- pending[index] = this.resolveOptionalAsync(
1112
- dep.token as Token<unknown> | Constructor,
1113
- depOptions,
1114
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1115
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1116
- );
1117
- } else {
1118
- pending[index] =
1119
- depOptions === undefined
1120
- ? this.resolveAsyncFromContext(
1121
- dep.token as Token<unknown> | Constructor,
1122
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1123
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1124
- )
1125
- : this.resolveAsync(
1126
- dep.token as Token<unknown> | Constructor,
1127
- depOptions,
1128
- shouldCloneContext ? [...resolutionPath] : resolutionPath,
1129
- shouldCloneContext ? [...resolutionStack] : resolutionStack,
1130
- );
1131
- }
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);
1132
951
  }
1133
- 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);
1134
959
  }
1135
960
 
1136
961
  async resolveOptionalAsync<const Value>(
@@ -1138,11 +963,12 @@ export class DependencyResolver implements ResolverCallbacks {
1138
963
  options: ResolveOptions | undefined,
1139
964
  resolutionPath: Array<string>,
1140
965
  resolutionStack: Array<ResolutionFrame>,
966
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
1141
967
  ): Promise<Value | undefined> {
1142
968
  if (this.#findBinding(token, options, resolutionPath, resolutionStack) === undefined) {
1143
969
  return undefined;
1144
970
  }
1145
- return this.resolveAsync(token, options, resolutionPath, resolutionStack);
971
+ return this.resolveAsync(token, options, resolutionPath, resolutionStack, branchDepth);
1146
972
  }
1147
973
 
1148
974
  async resolveAllAsync<const Value>(
@@ -1150,85 +976,88 @@ export class DependencyResolver implements ResolverCallbacks {
1150
976
  options: ResolveOptions | undefined,
1151
977
  resolutionPath: Array<string>,
1152
978
  resolutionStack: Array<ResolutionFrame>,
979
+ branchDepth: BranchDepth = UNOWNED_BRANCH,
1153
980
  ): Promise<Array<Value>> {
1154
- if (options?.name !== undefined && options.tag === undefined && (options.tags?.length ?? 0) === 0) {
1155
- const namedCandidates = this.#getSimpleNamedBindingsFromChain(token, options.name);
1156
- if (namedCandidates.length === 0) {
1157
- return [];
1158
- }
1159
- const pending = new Array<Promise<Value>>(namedCandidates.length);
1160
- for (let index = 0; index < namedCandidates.length; index += 1) {
1161
- pending[index] = this.#resolveCandidateAsync(
1162
- namedCandidates[index] as Binding<Value>,
1163
- options,
1164
- resolutionPath,
1165
- resolutionStack,
1166
- );
1167
- }
1168
- return Promise.all(pending);
1169
- }
1170
-
1171
- const allBindings = this.#getAllBindingsFromChain(token);
1172
- if (allBindings.length === 0) {
1173
- return [];
1174
- }
1175
-
1176
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
1177
- const candidates = selectAllBindings(allBindings, options, ctx);
1178
-
981
+ const candidates = this.#candidateBindings(token, options, resolutionPath, resolutionStack);
1179
982
  const pending = new Array<Promise<Value>>(candidates.length);
1180
983
  for (let index = 0; index < candidates.length; index += 1) {
1181
984
  pending[index] = this.#resolveCandidateAsync(
1182
- candidates[index] as Binding<Value>,
985
+ candidates[index]!,
1183
986
  options,
1184
987
  resolutionPath,
1185
988
  resolutionStack,
1186
- );
989
+ branchDepth,
990
+ ) as Promise<Value>;
1187
991
  }
1188
992
  return Promise.all(pending);
1189
993
  }
1190
994
 
1191
995
  // ── Helpers ────────────────────────────────────────────────────────────────
1192
996
 
1193
- #getAllBindingsFromChain(token: Token<unknown> | Constructor): ReadonlyArray<Binding> {
997
+ #allBindingsFromChain(token: Token<unknown> | Constructor): ReadonlyArray<Binding> {
1194
998
  const ownBindings = this.#registry.getAll(token);
1195
999
  if (this.#parent === undefined) {
1196
1000
  return ownBindings;
1197
1001
  }
1198
1002
  const result: Array<Binding> = [...ownBindings];
1199
- let current: DependencyResolver | undefined = this.#parent;
1200
- while (current !== undefined) {
1003
+ for (let current: DependencyResolver | undefined = this.#parent; current !== undefined; current = current.#parent) {
1201
1004
  const own = current.#registry.getAll(token);
1202
1005
  if (own.length > 0) {
1203
1006
  result.push(...own);
1204
1007
  }
1205
- current = current.#parent;
1206
1008
  }
1207
1009
  return result;
1208
1010
  }
1209
1011
 
1210
- #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.
1211
1016
  const ownBinding = this.#registry.getSimpleNamed(token, name);
1212
1017
  if (this.#parent === undefined) {
1213
- return ownBinding !== undefined ? [ownBinding] : [];
1214
- }
1215
- const result: Array<Binding> = [];
1216
- if (ownBinding !== undefined) {
1217
- result.push(ownBinding);
1018
+ return ownBinding === undefined ? [] : [ownBinding];
1218
1019
  }
1219
- let current: DependencyResolver | undefined = this.#parent;
1220
- while (current !== undefined) {
1020
+ const result: Array<Binding> = ownBinding === undefined ? [] : [ownBinding];
1021
+ for (let current: DependencyResolver | undefined = this.#parent; current !== undefined; current = current.#parent) {
1221
1022
  const binding = current.#registry.getSimpleNamed(token, name);
1222
1023
  if (binding !== undefined) {
1223
1024
  result.push(binding);
1224
1025
  }
1225
- current = current.#parent;
1226
1026
  }
1227
1027
  return result;
1228
1028
  }
1229
1029
 
1230
- #getAvailableSlots(token: Token<unknown> | Constructor): Array<string> {
1231
- 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;
1232
1061
  }
1233
1062
 
1234
1063
  #makeConstraintContext(
@@ -1239,13 +1068,11 @@ export class DependencyResolver implements ResolverCallbacks {
1239
1068
  if (options === undefined && resolutionPath.length === 0 && resolutionStack.length === 0) {
1240
1069
  return ROOT_CONSTRAINT_CONTEXT;
1241
1070
  }
1242
- const parent = resolutionStack.at(-1);
1243
- const ancestors = resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [];
1244
1071
  return {
1245
1072
  resolutionPath,
1246
1073
  resolutionStack,
1247
- parent,
1248
- ancestors,
1074
+ parent: resolutionStack.at(-1),
1075
+ ancestors: resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [],
1249
1076
  currentResolveOptions: options,
1250
1077
  };
1251
1078
  }
@@ -1256,80 +1083,20 @@ export class DependencyResolver implements ResolverCallbacks {
1256
1083
  resolutionPath: Array<string>,
1257
1084
  resolutionStack: Array<ResolutionFrame>,
1258
1085
  ): boolean {
1259
- if (!this.#matchesSlotFast(binding.slot, options)) {
1086
+ if (!matchesSlot(binding.slot, options)) {
1260
1087
  return false;
1261
1088
  }
1262
1089
  if (binding.predicate === undefined) {
1263
1090
  return true;
1264
1091
  }
1265
- const ctx = this.#makeConstraintContext(resolutionPath, resolutionStack, options);
1266
- return binding.predicate(ctx);
1267
- }
1268
-
1269
- #matchesSlotFast(slot: BindingSlot, options: ResolveOptions | undefined): boolean {
1270
- const requestedName = options?.name;
1271
- const requestedTags = options?.tags;
1272
- const singleRequestedTag = options?.tag;
1273
- const hasRequestedTags = (requestedTags?.length ?? 0) > 0 || singleRequestedTag !== undefined;
1274
-
1275
- if (slot.name !== undefined) {
1276
- if (requestedName === undefined || slot.name !== requestedName) {
1277
- return false;
1278
- }
1279
- } else if (requestedName !== undefined) {
1280
- return false;
1281
- }
1282
-
1283
- if (slot.tags.length > 0) {
1284
- if (!hasRequestedTags) {
1285
- return false;
1286
- }
1287
- for (const [tagKey, tagValue] of slot.tags) {
1288
- if (!this.#matchesRequestedTag(tagKey, tagValue, requestedTags, singleRequestedTag)) {
1289
- return false;
1290
- }
1291
- }
1292
- } else if (hasRequestedTags) {
1293
- return false;
1294
- }
1295
-
1296
- return true;
1297
- }
1298
-
1299
- #getTokenName(token: Token<unknown> | Constructor): string {
1300
- return tokenName(token);
1301
- }
1302
-
1303
- #matchesRequestedTag(
1304
- tagKey: string,
1305
- tagValue: unknown,
1306
- requestedTags: ReadonlyArray<BindingTag> | undefined,
1307
- singleRequestedTag: BindingTag | undefined,
1308
- ): boolean {
1309
- if (
1310
- singleRequestedTag !== undefined &&
1311
- singleRequestedTag[0] === tagKey &&
1312
- Object.is(singleRequestedTag[1], tagValue)
1313
- ) {
1314
- return true;
1315
- }
1316
- if (requestedTags === undefined || requestedTags.length === 0) {
1317
- return false;
1318
- }
1319
- for (let index = 0; index < requestedTags.length; index += 1) {
1320
- const requestedTag = requestedTags[index]!;
1321
- if (requestedTag[0] === tagKey && Object.is(requestedTag[1], tagValue)) {
1322
- return true;
1323
- }
1324
- }
1325
- return false;
1092
+ return binding.predicate(this.#makeConstraintContext(resolutionPath, resolutionStack, options));
1326
1093
  }
1327
1094
 
1328
- #resolveTransientDynamicSyncFromContext<const Value>(
1329
- binding: Binding<Value> & { kind: "dynamic" },
1095
+ #resolveTransientDynamicSyncFromContext(
1096
+ binding: DynamicBinding<unknown>,
1330
1097
  resolutionPath: Array<string>,
1331
1098
  resolutionStack: Array<ResolutionFrame>,
1332
- ): Value {
1099
+ ): unknown {
1333
1100
  // One lane at every depth: `binding.inFlight` is O(1), so there is nothing to escape.
1334
1101
  const frame = this.#getResolutionFrame(binding);
1335
1102
  const tokenDisplayName = frame.tokenName;
@@ -1354,143 +1121,148 @@ export class DependencyResolver implements ResolverCallbacks {
1354
1121
  }
1355
1122
 
1356
1123
  // Deliberately not `async`: that would allocate a state machine and a promise per level.
1357
- #resolveTransientDynamicAsyncFromContext<const Value>(
1358
- binding: Binding<Value> & { kind: "dynamic" | "dynamic-async" },
1124
+ #resolveTransientDynamicAsyncFromContext(
1125
+ binding: DynamicBinding<unknown> | DynamicAsyncBinding<unknown>,
1359
1126
  resolutionPath: Array<string>,
1360
1127
  resolutionStack: Array<ResolutionFrame>,
1361
- callerContext?: DefaultResolutionContext,
1362
- ): Promise<Value> {
1363
- // Path-scoped cycle detection, because async chains interleave — see ARCHITECTURE.md.
1364
- const pool = this.#asyncChainContextPool;
1128
+ branchDepth: BranchDepth,
1129
+ ): Promise<unknown> {
1365
1130
  const frame = this.#getResolutionFrame(binding);
1366
- const tokenDisplayName = frame.tokenName;
1131
+ let levelPath: OwnedBranchPath;
1367
1132
  try {
1368
- enterResolutionPath(resolutionPath, tokenDisplayName, false);
1133
+ levelPath = extendResolutionBranch(resolutionPath, branchDepth, frame.tokenName);
1369
1134
  } catch (cycleError) {
1370
1135
  // This method is not `async`; keep failures as rejections rather than sync throws.
1371
1136
  return Promise.reject(cycleError);
1372
1137
  }
1138
+ const levelStack = extendResolutionStackBranch(resolutionStack, branchDepth, frame);
1373
1139
 
1374
- // An inner level reuses the context its caller passed down; only a chain's first level
1375
- // borrows from the pool. Pooling is load-bearing here — see ARCHITECTURE.md.
1376
- const ctx =
1377
- callerContext !== undefined && callerContext.owner === this
1378
- ? callerContext
1379
- : this.#acquireAsyncChainContext(resolutionPath, resolutionStack);
1380
- ctx.chainLevels += 1;
1381
-
1382
- // Invoke the factory synchronously to get its Promise (or a resolved value for "dynamic").
1383
- let factoryPromise: Promise<Value>;
1140
+ // Nothing this level appended is ever removed, so no level observes its own settlement.
1141
+ const ctx = new AsyncLevelContext(this, levelPath, levelStack, undefined);
1384
1142
  try {
1385
1143
  if (binding.kind === "dynamic-async") {
1386
- factoryPromise = binding.factory(ctx);
1387
- } else {
1388
- const factoryResult = binding.factory(ctx);
1389
- factoryPromise =
1390
- factoryResult instanceof Promise ? (factoryResult as Promise<Value>) : Promise.resolve(factoryResult);
1144
+ return binding.factory(ctx);
1391
1145
  }
1146
+ const factoryResult = binding.factory(ctx);
1147
+ return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1392
1148
  } catch (factoryError) {
1393
- // Synchronous throw from the factory (rare) — clean up immediately.
1394
- exitResolutionPath(resolutionPath);
1395
- ctx.chainLevels -= 1;
1396
- if (ctx.chainLevels === 0) {
1397
- pool.push(ctx);
1398
- }
1399
1149
  return Promise.reject(factoryError);
1400
1150
  }
1151
+ }
1401
1152
 
1402
- // A side listener, not a derived chain: FIFO puts it before the awaiting caller resumes.
1403
- // It marks a rejection as handled, so callers must await or `.catch` the returned promise.
1404
- const settle =
1405
- ctx.chainSettle ??
1406
- (ctx.chainSettle = (): void => {
1407
- exitResolutionPath(resolutionPath);
1408
- ctx.chainLevels -= 1;
1409
- if (ctx.chainLevels === 0) {
1410
- pool.push(ctx);
1411
- }
1412
- });
1413
- factoryPromise.then(settle, settle);
1414
- return factoryPromise;
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);
1164
+ }
1165
+ return this.#dispatchCascade(token);
1415
1166
  }
1416
1167
 
1417
- #acquireAsyncChainContext(
1418
- resolutionPath: Array<string>,
1419
- resolutionStack: Array<ResolutionFrame>,
1420
- ): DefaultResolutionContext {
1421
- const pooled = this.#asyncChainContextPool.pop();
1422
- if (pooled === undefined) {
1423
- return new DefaultResolutionContext(this, resolutionPath, resolutionStack, undefined);
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
+ }
1424
1191
  }
1425
- pooled.reset(this, resolutionPath, resolutionStack, undefined);
1426
- return pooled;
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);
1427
1195
  }
1428
1196
 
1429
- #resolveCandidateSync<const Value>(
1430
- binding: Binding<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);
1211
+ try {
1212
+ if (binding.kind === "dynamic-async") {
1213
+ return binding.factory(ctx);
1214
+ }
1215
+ const factoryResult = binding.factory(ctx);
1216
+ return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1217
+ } catch (factoryError) {
1218
+ return Promise.reject(factoryError);
1219
+ } finally {
1220
+ this.#cascadeStack.pop();
1221
+ this.#cascadePath.pop();
1222
+ binding.inFlight = false;
1223
+ }
1224
+ }
1225
+
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,
1431
1230
  options: ResolveOptions | undefined,
1432
1231
  resolutionPath: Array<string>,
1433
1232
  resolutionStack: Array<ResolutionFrame>,
1434
- ): Value {
1435
- if (
1436
- binding.kind === "constant" &&
1437
- binding.onActivation === undefined &&
1438
- !this.#lifecycle.hasActivationHandlers(binding.token)
1439
- ) {
1233
+ ): unknown {
1234
+ if (this.#isPlainConstant(binding)) {
1440
1235
  return binding.value;
1441
1236
  }
1442
1237
  if (binding.kind === "alias") {
1443
1238
  return this.resolve(binding.target, options, resolutionPath, resolutionStack);
1444
1239
  }
1445
- const scope = (binding as BindingWithScope).scope ?? "transient";
1446
- if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
1447
- return binding.instance as Value;
1448
- }
1449
- if (scope === "scoped") {
1450
- if (!this.#scope.isChild) {
1451
- throw new MissingScopeContextError(this.#getTokenName(binding.token));
1452
- }
1453
- if (this.#scope.hasScoped(binding.id)) {
1454
- return this.#scope.getScoped<Value>(binding.id);
1455
- }
1240
+ if (binding.scope === "singleton" && binding.instance !== NO_INSTANCE) {
1241
+ return binding.instance;
1456
1242
  }
1457
1243
  return this.#resolveBinding(binding, options, resolutionPath, resolutionStack);
1458
1244
  }
1459
1245
 
1460
- #resolveCandidateAsync<const Value>(
1461
- binding: Binding<Value>,
1246
+ #resolveCandidateAsync(
1247
+ binding: Binding,
1462
1248
  options: ResolveOptions | undefined,
1463
1249
  resolutionPath: Array<string>,
1464
1250
  resolutionStack: Array<ResolutionFrame>,
1465
- ): Promise<Value> {
1466
- if (
1467
- binding.kind === "constant" &&
1468
- binding.onActivation === undefined &&
1469
- !this.#lifecycle.hasActivationHandlers(binding.token)
1470
- ) {
1251
+ branchDepth: BranchDepth,
1252
+ ): Promise<unknown> {
1253
+ if (this.#isPlainConstant(binding)) {
1471
1254
  return Promise.resolve(binding.value);
1472
1255
  }
1473
- const isolatedPath = [...resolutionPath];
1474
- const isolatedStack = [...resolutionStack];
1475
1256
  if (binding.kind === "alias") {
1476
- return this.resolveAsync(binding.target, options, isolatedPath, isolatedStack);
1477
- }
1478
- const scope = (binding as BindingWithScope).scope ?? "transient";
1479
- if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
1480
- return Promise.resolve(binding.instance as Value);
1257
+ return this.resolveAsync(binding.target, options, resolutionPath, resolutionStack, branchDepth);
1481
1258
  }
1482
- if (scope === "scoped") {
1483
- if (!this.#scope.isChild) {
1484
- return Promise.reject(new MissingScopeContextError(this.#getTokenName(binding.token)));
1485
- }
1486
- if (this.#scope.hasScoped(binding.id)) {
1487
- return Promise.resolve(this.#scope.getScoped<Value>(binding.id));
1488
- }
1259
+ if (binding.scope === "singleton" && binding.instance !== NO_INSTANCE) {
1260
+ return Promise.resolve(binding.instance);
1489
1261
  }
1490
- return this.#resolveBindingAsync(binding, options, isolatedPath, isolatedStack);
1262
+ return this.#resolveBindingAsync(binding, options, resolutionPath, resolutionStack, branchDepth);
1491
1263
  }
1492
1264
 
1493
- #getResolutionFrame<const Value>(binding: Binding<Value>): ResolutionFrame {
1265
+ #getResolutionFrame(binding: Binding): ResolutionFrame {
1494
1266
  // Memoized on the binding rather than in a per-resolver Map: the frame derives only from
1495
1267
  // immutable binding fields, so it is identical for every resolver, and a field read beats a
1496
1268
  // Map lookup on every hop of a chain.
@@ -1498,16 +1270,11 @@ export class DependencyResolver implements ResolverCallbacks {
1498
1270
  if (existing !== undefined) {
1499
1271
  return existing;
1500
1272
  }
1501
- const scope = (binding as BindingWithScope).scope ?? "transient";
1502
- 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);
1503
1274
  binding.frame = frame;
1504
1275
  return frame;
1505
1276
  }
1506
1277
 
1507
- #requiresResolutionContext<const Value>(binding: Binding<Value>): boolean {
1508
- return binding.kind === "dynamic" || binding.kind === "dynamic-async";
1509
- }
1510
-
1511
1278
  #acquireSyncResolutionContext(
1512
1279
  resolutionPath: Array<string>,
1513
1280
  resolutionStack: Array<ResolutionFrame>,
@@ -1524,3 +1291,33 @@ export class DependencyResolver implements ResolverCallbacks {
1524
1291
  return created;
1525
1292
  }
1526
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
+ }