@codefast/di 0.10.0 → 0.11.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 (81) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +71 -7
  3. package/dist/ambient/active-container.d.ts +13 -11
  4. package/dist/ambient/active-container.js +8 -6
  5. package/dist/container/binding-builders.d.ts +25 -7
  6. package/dist/container/binding-builders.js +110 -42
  7. package/dist/container/container.d.ts +8 -8
  8. package/dist/container/container.js +244 -67
  9. package/dist/core/binding-declaration.d.ts +120 -0
  10. package/dist/core/binding-declaration.js +186 -0
  11. package/dist/core/binding-scope.d.ts +2 -2
  12. package/dist/core/binding.d.ts +61 -9
  13. package/dist/core/binding.js +49 -2
  14. package/dist/core/constraint-requirement.d.ts +1 -1
  15. package/dist/core/module.d.ts +10 -7
  16. package/dist/core/module.js +17 -3
  17. package/dist/core/registry.d.ts +17 -13
  18. package/dist/core/registry.js +155 -59
  19. package/dist/core/state-epoch.d.ts +18 -1
  20. package/dist/core/state-epoch.js +17 -0
  21. package/dist/core/tag.js +1 -1
  22. package/dist/core/token.d.ts +1 -1
  23. package/dist/core/types.d.ts +5 -5
  24. package/dist/decorators/decorator-metadata.d.ts +9 -0
  25. package/dist/decorators/decorator-metadata.js +20 -0
  26. package/dist/decorators/inject.d.ts +3 -3
  27. package/dist/decorators/inject.js +7 -6
  28. package/dist/decorators/injectable.d.ts +2 -2
  29. package/dist/decorators/injectable.js +5 -3
  30. package/dist/decorators/lifecycle-decorators.js +9 -3
  31. package/dist/errors/errors.d.ts +85 -14
  32. package/dist/errors/errors.js +100 -20
  33. package/dist/index.d.ts +37 -35
  34. package/dist/index.js +20 -19
  35. package/dist/injection/descriptor.d.ts +3 -3
  36. package/dist/injection/descriptor.js +3 -7
  37. package/dist/injection/resolve-options.d.ts +3 -3
  38. package/dist/injection/resolve-options.js +7 -5
  39. package/dist/introspection/dependency-graph.d.ts +10 -5
  40. package/dist/introspection/dependency-graph.js +51 -28
  41. package/dist/introspection/graph-adapters/cytoscape.d.ts +1 -1
  42. package/dist/introspection/graph-adapters/dot.d.ts +1 -1
  43. package/dist/introspection/graph-adapters/mermaid.d.ts +1 -1
  44. package/dist/introspection/graph-adapters/reactflow.d.ts +1 -1
  45. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  46. package/dist/introspection/inspector.d.ts +4 -4
  47. package/dist/introspection/inspector.js +9 -12
  48. package/dist/lifecycle/lifecycle-manager.d.ts +4 -4
  49. package/dist/lifecycle/lifecycle-manager.js +18 -6
  50. package/dist/lifecycle/scope-manager.d.ts +2 -2
  51. package/dist/lifecycle/scope-manager.js +31 -13
  52. package/dist/metadata/metadata-reader-token.d.ts +2 -2
  53. package/dist/metadata/metadata-reader-token.js +1 -1
  54. package/dist/metadata/metadata-types.d.ts +3 -3
  55. package/dist/metadata/symbol-metadata-reader.d.ts +3 -3
  56. package/dist/metadata/symbol-metadata-reader.js +1 -1
  57. package/dist/metadata/verifying-metadata-reader.d.ts +5 -4
  58. package/dist/metadata/verifying-metadata-reader.js +30 -8
  59. package/dist/resolution/async-fan-out.d.ts +12 -0
  60. package/dist/resolution/async-fan-out.js +26 -0
  61. package/dist/resolution/cache/activation-need.d.ts +4 -5
  62. package/dist/resolution/cache/activation-need.js +11 -18
  63. package/dist/resolution/cache/binding-lookup-cache.d.ts +5 -12
  64. package/dist/resolution/cache/binding-lookup-cache.js +32 -19
  65. package/dist/resolution/cache/class-introspector.d.ts +16 -5
  66. package/dist/resolution/cache/class-introspector.js +73 -56
  67. package/dist/resolution/context.d.ts +17 -25
  68. package/dist/resolution/context.js +47 -56
  69. package/dist/resolution/path/resolution-path.d.ts +48 -13
  70. package/dist/resolution/path/resolution-path.js +90 -39
  71. package/dist/resolution/plan/instantiation-plan.d.ts +5 -5
  72. package/dist/resolution/plan/instantiation-plan.js +66 -26
  73. package/dist/resolution/plan/plan-codegen.d.ts +10 -7
  74. package/dist/resolution/plan/plan-codegen.js +62 -34
  75. package/dist/resolution/resolver.d.ts +16 -17
  76. package/dist/resolution/resolver.js +316 -273
  77. package/dist/resolution/select/binding-select.d.ts +6 -5
  78. package/dist/resolution/select/binding-select.js +5 -4
  79. package/dist/resolution/select/constraints.d.ts +3 -3
  80. package/dist/resolution/select/constraints.js +4 -4
  81. package/package.json +14 -2
@@ -1,19 +1,17 @@
1
- import { NO_INSTANCE } from "#/core/binding";
2
- import { NO_TAG_KEYS, slotNameCriterionOf } from "#/core/tag";
3
- import { tokenName } from "#/core/token";
4
- import { AsyncActivationError, AsyncResolutionError, CircularDependencyError, DisposedContainerError, InternalError, MissingMetadataError, MissingScopeContextError, NoMatchingBindingError, TokenNotBoundError, } from "#/errors/errors";
5
- import { loneTagBesideNameOf, resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf, } from "#/injection/resolve-options";
6
- import { SCOPED_MISS } from "#/lifecycle/scope-manager";
7
- import { ActivationNeedCache } from "#/resolution/cache/activation-need";
8
- import { BindingLookupCache } from "#/resolution/cache/binding-lookup-cache";
9
- import { ClassIntrospector } from "#/resolution/cache/class-introspector";
10
- import { AsyncCascadeContext, AsyncLevelContext, buildResolutionFrame, DefaultResolutionContext, } from "#/resolution/context";
11
- import { branchDepthOf, cycleNamesOf, enterResolutionPath, extendResolutionBranch, ROOT_BRANCH, UNOWNED_BRANCH, } from "#/resolution/path/resolution-path";
12
- import { InstantiationPlanCompiler, PLAN_RETRY } from "#/resolution/plan/instantiation-plan";
13
- import { matchesSlot, requestedTagKeyMask, selectAllBindings, selectBinding } from "#/resolution/select/binding-select";
14
- // Where a multi-tag resolve switches from scanning the token's list to walking the tag indexes.
15
- const MULTI_TAG_INDEX_THRESHOLD = 8;
16
- const EMPTY_STRING_LIST = [];
1
+ import { bindingSlotToString, NO_INSTANCE } from "#core/binding";
2
+ import { slotNameCriterionOf } from "#core/tag";
3
+ import { tokenName } from "#core/token";
4
+ import { AsyncActivationError, AsyncResolutionError, CircularDependencyError, DisposedContainerError, InternalError, MissingMetadataError, MissingScopeContextError, NoMatchingBindingError, TokenNotBoundError, } from "#errors/errors";
5
+ import { loneTagBesideNameOf, resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf, } from "#injection/resolve-options";
6
+ import { SCOPED_MISS } from "#lifecycle/scope-manager";
7
+ import { settleInOrder } from "#resolution/async-fan-out";
8
+ import { ActivationNeedCache } from "#resolution/cache/activation-need";
9
+ import { BindingLookupCache } from "#resolution/cache/binding-lookup-cache";
10
+ import { ClassIntrospector } from "#resolution/cache/class-introspector";
11
+ import { AsyncLevelContext, DefaultConstraintContext, DefaultResolutionContext } from "#resolution/context";
12
+ import { branchDepthOf, buildResolutionFrame, cycleNamesOf, enterSyncPath, extendResolutionBranch, leaveSyncPath, ROOT_BRANCH, UNOWNED_BRANCH, } from "#resolution/path/resolution-path";
13
+ import { InstantiationPlanCompiler, PLAN_RETRY } from "#resolution/plan/instantiation-plan";
14
+ import { matchesSlot, selectAllBindings, selectBinding } from "#resolution/select/binding-select";
17
15
  const EMPTY_FRAME_LIST = [];
18
16
  const EMPTY_PARAM_LIST = [];
19
17
  /** Plans compiled so far, with the `null` unplannable marks left out. */
@@ -28,22 +26,15 @@ function countCompiledPlans(plans) {
28
26
  }
29
27
  return count;
30
28
  }
31
- const ROOT_CONSTRAINT_CONTEXT = {
32
- resolutionPath: EMPTY_STRING_LIST,
33
- resolutionStack: EMPTY_FRAME_LIST,
34
- parent: undefined,
35
- ancestors: EMPTY_FRAME_LIST,
36
- currentResolveOptions: undefined,
37
- };
29
+ const ROOT_CONSTRAINT_CONTEXT = new DefaultConstraintContext(EMPTY_FRAME_LIST, undefined);
38
30
  /**
39
31
  * The resolution engine driving binding selection, instantiation, scoping, and lifecycle hooks.
40
32
  *
41
33
  * @since 0.3.16-canary.0
42
34
  */
43
35
  export class DependencyResolver {
44
- #syncResolutionContextPool = [];
45
- // Contexts bound to the cascade pair — deferred: only an async cascade's sync resolves need it.
46
- #cascadeContextPool;
36
+ // Contexts pooled by depth over the root stack — deferred: a plan-served or constant-only container never needs one.
37
+ #syncResolutionContextPool;
47
38
  /**
48
39
  * The stack a top-level **sync** resolve reuses instead of minting an array per call.
49
40
  *
@@ -53,25 +44,31 @@ export class DependencyResolver {
53
44
  * mints its own. Keeping it stable is also what lets a pooled context skip re-storing it.
54
45
  */
55
46
  rootStack = [];
56
- // The open synchronous factory cascade: its stack is the ancestor chain, and it is balanced
57
- // because synchronous code does not interleave.
58
- #cascadeStack = [];
59
- #cascadeContext;
47
+ // The last root level's context, kept so one instance is always live: a full collection that finds
48
+ // none deoptimizes every optimized site that embedded the class's shape, and does so on every
49
+ // collection after — measured on the async chain rows, and removed by this one field.
50
+ #recentRootLevelContext;
60
51
  // Compiled plans; `null` marks a binding as unplannable under the current cache versions. Both
61
- // maps are allocated by the first plan request, which only a class or resolved binding makes.
52
+ // maps are allocated by the first plan request, which only a class or resolved binding makes. A
53
+ // root is compiled on the request that repeats it — the first interprets, so a container that
54
+ // resolves a root once never compiles — and the set below remembers the first.
62
55
  #classPlanByBindingId;
56
+ #classPlanRequestedOnce;
63
57
  #classPlanRegistryVersion = -1;
64
58
  #classPlanActivationVersion = -1;
65
59
  // The async lane's plans, stamped and invalidated apart so neither lane pays the other's misses.
66
60
  #asyncPlanByBindingId;
61
+ #asyncPlanRequestedOnce;
67
62
  #asyncPlanRegistryVersion = -1;
68
63
  #asyncPlanActivationVersion = -1;
69
64
  #registry;
70
65
  #scope;
71
66
  #lifecycle;
72
67
  #metadataReader;
68
+ #container;
73
69
  #parent;
74
70
  #lookup;
71
+ // Built by the first class resolve: a container that never resolves a class never pays for it.
75
72
  #classes;
76
73
  // Built by the first interpreted resolve that asks; a plan-served or constant-only container never does.
77
74
  #activation;
@@ -80,16 +77,19 @@ export class DependencyResolver {
80
77
  this.#scope = scope;
81
78
  this.#lifecycle = lifecycle;
82
79
  this.#metadataReader = metadataReader;
80
+ this.#container = container;
83
81
  this.#parent = parent;
84
82
  this.#lookup = new BindingLookupCache(registry, this, parent === undefined ? undefined : parent.#lookup);
85
- this.#classes = new ClassIntrospector(metadataReader, container, parent === undefined ? undefined : parent.#classes);
83
+ }
84
+ #introspector() {
85
+ return (this.#classes ??= new ClassIntrospector(this.#metadataReader, this.#container, this.#parent === undefined ? undefined : this.#parent.#introspector()));
86
86
  }
87
87
  /** The reader this resolver was built with, which is the one its container answers with. */
88
88
  get metadataReader() {
89
89
  return this.#metadataReader;
90
90
  }
91
91
  #activationNeed() {
92
- return (this.#activation ??= new ActivationNeedCache(this.#lifecycle, this.#classes, this.#registry));
92
+ return (this.#activation ??= new ActivationNeedCache(this.#lifecycle, this.#introspector(), this.#registry));
93
93
  }
94
94
  /** Structural counts and the resolver-owned collaborators built so far, for the {@link ResolutionDiagnostics} a container reports. */
95
95
  describeCaches() {
@@ -100,18 +100,18 @@ export class DependencyResolver {
100
100
  if (this.#lookup.isMemoBuilt) {
101
101
  builtSubsystems.push("resolver.lookupMemo");
102
102
  }
103
- if (this.#activation?.isMemoBuilt === true) {
104
- builtSubsystems.push("resolver.activationNeedMemo");
105
- }
106
103
  const generatedPlanCount = this.#planCompiler?.generatedPlanCount ?? 0;
107
104
  if (generatedPlanCount > 0) {
108
105
  builtSubsystems.push("resolver.planCodegen");
109
106
  }
107
+ if (this.#recentRootLevelContext !== undefined) {
108
+ builtSubsystems.push("resolver.asyncRootLevel");
109
+ }
110
110
  return {
111
111
  compiledPlanCount: countCompiledPlans(this.#classPlanByBindingId),
112
112
  compiledAsyncPlanCount: countCompiledPlans(this.#asyncPlanByBindingId),
113
113
  generatedPlanCount,
114
- syncContextPoolSize: this.#syncResolutionContextPool.length,
114
+ syncContextPoolSize: this.#syncResolutionContextPool?.length ?? 0,
115
115
  builtSubsystems,
116
116
  };
117
117
  }
@@ -134,23 +134,19 @@ export class DependencyResolver {
134
134
  if (indexed === undefined) {
135
135
  // A one-criterion request matches only a slot carrying exactly that criterion, and every such
136
136
  // slot is in the index, so a miss here is a miss for this registry: nothing left to scan.
137
- return this.#parent === undefined
138
- ? undefined
139
- : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
137
+ return this.#forwardingAliasOrParent(token, options, resolutionStack, singleCriterion);
140
138
  }
141
139
  if (this.#satisfiesPredicate(indexed, options, resolutionStack)) {
142
140
  return { binding: indexed, owner: this };
143
141
  }
144
142
  }
145
- else {
146
- if (options.name !== undefined) {
147
- const pairTag = loneTagBesideNameOf(options);
148
- if (pairTag !== undefined) {
149
- const nameCriterion = slotNameCriterionOf(options.name);
150
- if (nameCriterion === undefined) {
151
- // No binding anywhere has declared this name, so no slot in any registry can carry it.
152
- return undefined;
153
- }
143
+ else if (options.name !== undefined) {
144
+ const pairTag = loneTagBesideNameOf(options);
145
+ if (pairTag !== undefined) {
146
+ const nameCriterion = slotNameCriterionOf(options.name);
147
+ // A name interned nowhere cannot key a slot, but a slot whose criteria are a subset of the
148
+ // request's still matches — so an index miss falls through to the scan, never a clean miss.
149
+ if (nameCriterion !== undefined) {
154
150
  // The exact two-criterion slot, memoized over the chain; a predicate or an alias declines to the scan.
155
151
  const entry = this.#lookup.namedTaggedEntry(token, nameCriterion, pairTag);
156
152
  if (entry !== null) {
@@ -158,22 +154,6 @@ export class DependencyResolver {
158
154
  }
159
155
  }
160
156
  }
161
- if (
162
- // A threshold switches the data structure, never the semantics: under it the generic scan
163
- // below beats walking the indexes, and both paths answer identically. Sized first, so a
164
- // small list pays one length read and nothing else.
165
- this.#registry.countBindings(token) > MULTI_TAG_INDEX_THRESHOLD &&
166
- requestedTagKeyMask(options) !== NO_TAG_KEYS) {
167
- // A multi-criterion request matches only slots whose every criterion it carries, and every
168
- // such slot is in the two tag indexes — their union is the whole candidate set, unscanned.
169
- const selected = this.#selectMultiTagged(token, options, resolutionStack);
170
- if (selected !== undefined) {
171
- return { binding: selected, owner: this };
172
- }
173
- return this.#parent === undefined
174
- ? undefined
175
- : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
176
- }
177
157
  }
178
158
  // A lone default-slot candidate is its own selection: the slot match is the whole decision,
179
159
  // it carries no predicate, and asking for it first keeps the registry from materialising its list.
@@ -194,10 +174,29 @@ export class DependencyResolver {
194
174
  }
195
175
  }
196
176
  }
197
- if (this.#parent !== undefined) {
198
- return this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
177
+ if (options === undefined) {
178
+ return this.#parent === undefined
179
+ ? undefined
180
+ : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
199
181
  }
200
- return undefined;
182
+ return this.#forwardingAliasOrParent(token, options, resolutionStack, singleCriterion);
183
+ }
184
+ /**
185
+ * What a request with criteria finds once no slot of this registry matches them: the token's
186
+ * default-slot alias, which forwards them to its target, else the parent's answer.
187
+ *
188
+ * @remarks A default-slot alias carries no criterion, predicate or membership, so it is a pointer
189
+ * rather than a candidate — every exact slot here was tried first, and the nearest container that
190
+ * can answer still does.
191
+ */
192
+ #forwardingAliasOrParent(token, options, resolutionStack, singleCriterion) {
193
+ const defaultSlot = this.#registry.getDefaultSlotBinding(token);
194
+ if (defaultSlot !== undefined && defaultSlot.kind === "alias") {
195
+ return { binding: defaultSlot, owner: this };
196
+ }
197
+ return this.#parent === undefined
198
+ ? undefined
199
+ : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
201
200
  }
202
201
  /**
203
202
  * The scan a candidate list gets before full selection.
@@ -248,14 +247,40 @@ export class DependencyResolver {
248
247
  }
249
248
  if (found === undefined) {
250
249
  // Thrown here rather than from a helper: the error captures this stack, and an error path is
251
- // dominated by that capture. Bindings under the token mean the request matched none of them.
252
- if (this.#registry.getAll(currentToken).length > 0) {
253
- throw new NoMatchingBindingError(tokenName(currentToken), options ?? {}, this.#registry.availableSlotStrings(currentToken));
250
+ // dominated by that capture. Bindings under the token anywhere in the chain mean the request
251
+ // matched none of them, so a child reports the same miss its parent would.
252
+ const bound = this.#allBindingsFromChain(currentToken);
253
+ if (bound.length > 0) {
254
+ throw new NoMatchingBindingError(tokenName(currentToken), options ?? {}, bound.map((binding) => bindingSlotToString(binding.slot)));
254
255
  }
255
256
  throw new TokenNotBoundError(tokenName(currentToken));
256
257
  }
257
258
  return found;
258
259
  }
260
+ /**
261
+ * Follows an alias binding's chain to its terminal binding, or `undefined` when the chain ends at
262
+ * a token nothing matches.
263
+ *
264
+ * @remarks The non-throwing twin of {@link DependencyResolver.#requireBinding}'s alias walk, for
265
+ * the optional and collection lanes: a dangling chain is a miss, but a revisited alias token still
266
+ * raises {@link CircularDependencyError} — a cycle has no absent reading.
267
+ */
268
+ #terminalOfAliasBinding(alias, options, resolutionStack, singleCriterion) {
269
+ let currentToken = alias.token;
270
+ let found = { binding: alias, owner: this };
271
+ let visitedAliasTokens;
272
+ while (found !== undefined && found.binding.kind === "alias") {
273
+ const target = found.binding.target;
274
+ visitedAliasTokens ??= new Set([currentToken]);
275
+ if (visitedAliasTokens.has(target)) {
276
+ throw new CircularDependencyError([...visitedAliasTokens, target].map((entry) => tokenName(entry)));
277
+ }
278
+ visitedAliasTokens.add(target);
279
+ currentToken = target;
280
+ found = this.#findBinding(currentToken, options, resolutionStack, singleCriterion);
281
+ }
282
+ return found;
283
+ }
259
284
  /**
260
285
  * Binding lookup aligned with `resolve` — used by `Container.validate` without instantiating.
261
286
  */
@@ -346,6 +371,8 @@ export class DependencyResolver {
346
371
  if (binding.activationHook !== undefined) {
347
372
  const activationResult = binding.activationHook(resolutionCtx, activated);
348
373
  if (activationResult instanceof Promise) {
374
+ // The hook has already run; adopt its rejection so it cannot surface as unhandled.
375
+ void activationResult.catch(() => { });
349
376
  throw new AsyncActivationError(tokenDisplayName, "onActivation");
350
377
  }
351
378
  activated = activationResult;
@@ -354,6 +381,7 @@ export class DependencyResolver {
354
381
  for (let index = 0; index < containerHooks.length; index += 1) {
355
382
  const activationResult = containerHooks[index](resolutionCtx, activated);
356
383
  if (activationResult instanceof Promise) {
384
+ void activationResult.catch(() => { });
357
385
  throw new AsyncActivationError(tokenDisplayName, "onActivation");
358
386
  }
359
387
  activated = activationResult;
@@ -390,6 +418,7 @@ export class DependencyResolver {
390
418
  }
391
419
  else {
392
420
  this.#classPlanByBindingId.clear();
421
+ this.#classPlanRequestedOnce?.clear();
393
422
  }
394
423
  this.#classPlanRegistryVersion = registryVersion;
395
424
  this.#classPlanActivationVersion = activationVersion;
@@ -399,6 +428,12 @@ export class DependencyResolver {
399
428
  if (cached !== undefined) {
400
429
  return cached;
401
430
  }
431
+ const requestedOnce = (this.#classPlanRequestedOnce ??= new Set());
432
+ if (!requestedOnce.has(binding.identifier)) {
433
+ // The first request interprets; the one that repeats it compiles.
434
+ requestedOnce.add(binding.identifier);
435
+ return null;
436
+ }
402
437
  const compiled = this.#compiler().compile(binding);
403
438
  if (compiled === PLAN_RETRY) {
404
439
  // Lifecycle metadata not discovered yet — the fallback resolve discovers it; retry then.
@@ -411,25 +446,29 @@ export class DependencyResolver {
411
446
  // that never resolves a class or a resolved factory never pays for the host.
412
447
  #planCompiler;
413
448
  #compiler() {
414
- return (this.#planCompiler ??= new InstantiationPlanCompiler({
449
+ return (this.#planCompiler ??= new InstantiationPlanCompiler(this.#buildPlanCompilerHost()));
450
+ }
451
+ // The behaviour the plan compiler needs from this resolver — lookups, escapes, plan swaps, and the
452
+ // accessor construction path. Built once with the compiler, so each closure is allocated once.
453
+ #buildPlanCompilerHost() {
454
+ const classes = this.#introspector();
455
+ return {
415
456
  hasActivationHandlers: (binding) => this.#ownerOf(binding).#lifecycle.hasActivationHandlers(binding.token),
416
- knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
417
- needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
457
+ knownPostConstruct: (target) => classes.knownPostConstruct(target),
458
+ needsActiveContainer: (target) => classes.needsActiveContainer(target),
418
459
  // A plan runs at the top level, so the lent root stack is free when it is; a plan reached
419
460
  // with the root stack held mints its own path, exactly as the interpreted lane would.
420
461
  constructWithAccessors: (binding, target, deps) => {
421
462
  const stack = this.rootStack.length === 0 ? this.rootStack : [];
422
- const frame = this.#getResolutionFrame(binding);
423
- const resolutionSet = enterResolutionPath(stack, frame);
463
+ enterSyncPath(stack, binding, this.#getResolutionFrame(binding));
424
464
  try {
425
- return this.#classes.instantiate(target, deps, this.#ambientResolutionFor(stack));
465
+ return classes.instantiate(target, deps, this.#ambientResolutionFor(stack));
426
466
  }
427
467
  finally {
428
- stack.pop();
429
- resolutionSet?.delete(frame.bindingId);
468
+ leaveSyncPath(stack, binding);
430
469
  }
431
470
  },
432
- getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
471
+ getConstructorMetadata: (target) => classes.constructorMetadata(target),
433
472
  lookupDependencyEntry: (token) => {
434
473
  const entry = this.#lookup.defaultEntry(token);
435
474
  return entry === null ? null : { binding: entry.binding };
@@ -488,7 +527,7 @@ export class DependencyResolver {
488
527
  }
489
528
  return this.resolveAsync(token, options, resolutionStack, UNOWNED_BRANCH);
490
529
  },
491
- }));
530
+ };
492
531
  }
493
532
  /** The async lane's plan for a statically-visible transient binding, mirroring the sync getter. */
494
533
  #getAsyncInstantiationPlan(binding) {
@@ -500,6 +539,7 @@ export class DependencyResolver {
500
539
  }
501
540
  else {
502
541
  this.#asyncPlanByBindingId.clear();
542
+ this.#asyncPlanRequestedOnce?.clear();
503
543
  }
504
544
  this.#asyncPlanRegistryVersion = registryVersion;
505
545
  this.#asyncPlanActivationVersion = activationVersion;
@@ -509,6 +549,11 @@ export class DependencyResolver {
509
549
  if (cached !== undefined) {
510
550
  return cached;
511
551
  }
552
+ const requestedOnce = (this.#asyncPlanRequestedOnce ??= new Set());
553
+ if (!requestedOnce.has(binding.identifier)) {
554
+ requestedOnce.add(binding.identifier);
555
+ return null;
556
+ }
512
557
  const compiled = this.#compiler().compileAsync(binding);
513
558
  if (compiled === PLAN_RETRY) {
514
559
  // Lifecycle metadata not discovered yet — the fallback resolve discovers it; retry then.
@@ -576,7 +621,8 @@ export class DependencyResolver {
576
621
  }
577
622
  const frame = this.#getResolutionFrame(binding);
578
623
  const tokenDisplayName = frame.tokenName;
579
- const resolutionSet = enterResolutionPath(resolutionStack, frame);
624
+ // The level holds its binding's flag for its whole duration, so a factory below runs bare.
625
+ enterSyncPath(resolutionStack, binding, frame);
580
626
  try {
581
627
  const needsActivation = owner.#activationNeed().needsActivation(binding);
582
628
  if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
@@ -604,13 +650,15 @@ export class DependencyResolver {
604
650
  return activated;
605
651
  }
606
652
  finally {
607
- resolutionStack.pop();
608
- resolutionSet?.delete(frame.bindingId);
653
+ leaveSyncPath(resolutionStack, binding);
609
654
  }
610
655
  }
611
- /** Path-continuing resolution handed to the ambient slot while an accessor class constructs. */
612
- // The ambient resolution a top-level construction hands its accessors: the lent root stack is one
613
- // array for the resolver's lifetime, so the pair of closures over it is built once and reused.
656
+ /**
657
+ * Path-continuing resolution handed to the ambient slot while an accessor class constructs.
658
+ *
659
+ * @remarks The lent root stack is one array for the resolver's lifetime, so the closure pair over
660
+ * it is built once and reused.
661
+ */
614
662
  #rootAmbientResolution;
615
663
  #ambientResolutionFor(resolutionStack) {
616
664
  if (resolutionStack === this.rootStack) {
@@ -644,7 +692,9 @@ export class DependencyResolver {
644
692
  throw asyncResolutionErrorFor(binding, resolutionStack);
645
693
  case "class": {
646
694
  const deps = this.#resolveDeps(this.#constructorParams(binding.target), resolutionStack);
647
- return this.#classes.instantiate(binding.target, deps, this.#classes.needsActiveContainer(binding.target) ? this.#ambientResolutionFor(resolutionStack) : undefined);
695
+ return this.#introspector().instantiate(binding.target, deps, this.#introspector().needsActiveContainer(binding.target)
696
+ ? this.#ambientResolutionFor(resolutionStack)
697
+ : undefined);
648
698
  }
649
699
  case "resolved": {
650
700
  const deps = this.#resolveDeps(binding.deps, resolutionStack);
@@ -667,11 +717,20 @@ export class DependencyResolver {
667
717
  * no parameters; anything else is a missing `@injectable()`.
668
718
  */
669
719
  #constructorParams(target) {
670
- const meta = this.#classes.constructorMetadata(target);
720
+ const meta = this.#introspector().constructorMetadata(target);
671
721
  if (meta !== undefined) {
672
722
  return meta.params;
673
723
  }
674
724
  if (target.length === 0) {
725
+ // A subclass with an implicit constructor and no own metadata inherits its base's declared
726
+ // deps but would be built with zero arguments, injecting `undefined` silently — reject it.
727
+ const inherited = this.#introspector().inheritedConstructorMetadata(target);
728
+ if (inherited !== undefined && inherited.metadata.params.length > 0) {
729
+ throw new MissingMetadataError(target.name, {
730
+ baseName: inherited.base.name,
731
+ dependencyCount: inherited.metadata.params.length,
732
+ });
733
+ }
675
734
  return EMPTY_PARAM_LIST;
676
735
  }
677
736
  throw new MissingMetadataError(target.name);
@@ -719,16 +778,18 @@ export class DependencyResolver {
719
778
  return undefined;
720
779
  }
721
780
  }
722
- const entry = this.#findBinding(token, options, resolutionStack, precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined));
781
+ const singleCriterion = precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined);
782
+ let entry = this.#findBinding(token, options, resolutionStack, singleCriterion);
783
+ // Resolve the entry the probe found: re-looking the token up would evaluate every `when()`
784
+ // predicate a second time, and a changed answer would throw where `undefined` was promised.
785
+ if (entry !== undefined && entry.binding.kind === "alias") {
786
+ // Follow the alias off the throwing path: a chain that ends nowhere is a miss, not an error.
787
+ entry = this.#terminalOfAliasBinding(entry.binding, options, resolutionStack, singleCriterion);
788
+ }
723
789
  if (entry === undefined) {
724
790
  return undefined;
725
791
  }
726
- // Resolve the entry the probe found: re-looking the token up would evaluate every `when()`
727
- // predicate a second time, and a changed answer would throw where `undefined` was promised.
728
792
  const { binding, owner } = entry;
729
- if (binding.kind === "alias") {
730
- return this.resolve(token, options, resolutionStack);
731
- }
732
793
  if (binding.scope === "singleton" && owner !== this) {
733
794
  return owner.#resolveBinding(binding, options, resolutionStack, owner);
734
795
  }
@@ -753,7 +814,10 @@ export class DependencyResolver {
753
814
  const resolutionStack = this.rootStack;
754
815
  const memo = this.#rootCollection(token, resolutionStack);
755
816
  if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
756
- return memo.values;
817
+ // A copy, never the memo itself: a caller that writes into its result must not rewrite the
818
+ // next caller's. Reading a frozen array is several times slower than reading a plain one, so
819
+ // the copy is the cheaper guard by far, and it costs one allocation the read already paid before the memo.
820
+ return memo.values.slice();
757
821
  }
758
822
  const { candidates } = memo;
759
823
  const resolved = new Array(candidates.length);
@@ -770,9 +834,9 @@ export class DependencyResolver {
770
834
  const resolutionStack = [];
771
835
  const memo = this.#rootCollection(token, resolutionStack);
772
836
  if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
773
- return Promise.resolve(memo.values);
837
+ return Promise.resolve(memo.values.slice());
774
838
  }
775
- return Promise.all(memo.candidates.map((candidate) => this.#resolveCandidateAsync(candidate, undefined, resolutionStack, UNOWNED_BRANCH))).then((values) => {
839
+ return settleInOrder(memo.candidates.map((candidate) => this.#resolveCandidateAsync(candidate, undefined, resolutionStack, UNOWNED_BRANCH)), (values) => {
776
840
  this.#settleCollectionValues(memo);
777
841
  return values;
778
842
  });
@@ -811,8 +875,7 @@ export class DependencyResolver {
811
875
  return;
812
876
  }
813
877
  }
814
- // Handed out as is, unfrozen: a frozen array iterates through a slow elements kind, so the
815
- // contract's read-only return is the guard against a caller writing into the memo.
878
+ // Kept unfrozen: a frozen array reads through a slow elements kind, and every read copies it anyway.
816
879
  entry.values = candidates.map(stableMemberValue);
817
880
  entry.activationVersion = 0;
818
881
  }
@@ -821,14 +884,37 @@ export class DependencyResolver {
821
884
  if (options !== undefined) {
822
885
  const indexed = this.#indexedCandidates(token, options, resolutionStack);
823
886
  if (indexed !== null) {
824
- return indexed;
887
+ return this.#withoutDanglingAliases(indexed, options, resolutionStack);
825
888
  }
826
889
  }
827
890
  const allBindings = this.#allBindingsFromChain(token);
828
891
  if (allBindings.length === 0) {
829
892
  return allBindings;
830
893
  }
831
- return selectAllBindings(allBindings, options, this.#makeConstraintContext(resolutionStack, options));
894
+ const selected = selectAllBindings(allBindings, options, this.#makeConstraintContext(resolutionStack, options));
895
+ return this.#withoutDanglingAliases(selected, options, resolutionStack);
896
+ }
897
+ /**
898
+ * The candidates with any alias whose chain ends nowhere dropped, so a fan-out skips a dangling
899
+ * alias rather than throwing on it.
900
+ *
901
+ * @remarks A live alias stays and resolves as before; the common list carries no alias at all and
902
+ * is returned untouched, so nothing is allocated for it.
903
+ */
904
+ #withoutDanglingAliases(candidates, options, resolutionStack) {
905
+ let hasAlias = false;
906
+ for (let index = 0; index < candidates.length; index += 1) {
907
+ if (candidates[index].kind === "alias") {
908
+ hasAlias = true;
909
+ break;
910
+ }
911
+ }
912
+ if (!hasAlias) {
913
+ return candidates;
914
+ }
915
+ const singleCriterion = singleCriterionOnlyOf(options);
916
+ return candidates.filter((candidate) => candidate.kind !== "alias" ||
917
+ this.#terminalOfAliasBinding(candidate, options, resolutionStack, singleCriterion) !== undefined);
832
918
  }
833
919
  // ── Async resolve ──────────────────────────────────────────────────────────────────────────────────────────────────
834
920
  resolveAsyncFromContext(token, resolutionStack, branchDepth) {
@@ -890,11 +976,13 @@ export class DependencyResolver {
890
976
  return this.#resolveBindingAsync(binding, options, [], ROOT_BRANCH, this);
891
977
  }
892
978
  async resolveAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
893
- const { binding, owner } = this.#requireBinding(token, options, resolutionStack, precomputedCriterion);
979
+ const path = ownPrefixOf(resolutionStack, branchDepth);
980
+ const depth = path === resolutionStack ? branchDepth : branchDepthOf(path);
981
+ const { binding, owner } = this.#requireBinding(token, options, path, precomputedCriterion);
894
982
  if (binding.scope === "singleton" && owner !== this) {
895
- return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
983
+ return owner.#resolveBindingAsync(binding, options, path, depth, owner);
896
984
  }
897
- return this.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
985
+ return this.#resolveBindingAsync(binding, options, path, depth, owner);
898
986
  }
899
987
  async #resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner) {
900
988
  if (owner.#isPlainConstant(binding)) {
@@ -935,15 +1023,18 @@ export class DependencyResolver {
935
1023
  const needsActivation = owner.#activationNeed().needsActivation(binding);
936
1024
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
937
1025
  const resolutionCtx = new AsyncLevelContext(this, levelStack, options);
938
- if (binding.kind === "dynamic-async") {
939
- return await binding.factory(resolutionCtx);
1026
+ if (branchDepth === ROOT_BRANCH) {
1027
+ this.#recentRootLevelContext = resolutionCtx;
940
1028
  }
941
- const dynamicResult = binding.factory(resolutionCtx);
1029
+ const dynamicResult = runFactoryPrefix(binding, resolutionCtx, levelStack);
942
1030
  return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
943
1031
  }
944
1032
  const resolutionCtx = needsActivation || requiresResolutionContext(binding)
945
1033
  ? new AsyncLevelContext(this, levelStack, options)
946
1034
  : undefined;
1035
+ if (resolutionCtx !== undefined && branchDepth === ROOT_BRANCH) {
1036
+ this.#recentRootLevelContext = resolutionCtx;
1037
+ }
947
1038
  if (scope === "singleton") {
948
1039
  // The promise is published before it settles, so concurrent callers dedup onto it.
949
1040
  const singletonPromise = this.#instantiateAndActivateAsync(binding, resolutionCtx, levelStack, levelDepth, needsActivation, owner).then((activated) => {
@@ -980,8 +1071,10 @@ export class DependencyResolver {
980
1071
  * this binding on every call, forever.
981
1072
  */
982
1073
  #mirrorPostConstructFromOwner(binding, owner) {
983
- if (owner !== this && binding.kind === "class" && this.#classes.knownPostConstruct(binding.target) === undefined) {
984
- this.#classes.discoverPostConstruct(binding.target);
1074
+ if (owner !== this &&
1075
+ binding.kind === "class" &&
1076
+ this.#introspector().knownPostConstruct(binding.target) === undefined) {
1077
+ this.#introspector().discoverPostConstruct(binding.target);
985
1078
  }
986
1079
  }
987
1080
  async #instantiateAndActivateAsync(binding, ctx, resolutionStack, branchDepth, needsActivation, owner) {
@@ -996,22 +1089,20 @@ export class DependencyResolver {
996
1089
  switch (binding.kind) {
997
1090
  case "constant":
998
1091
  return binding.value;
999
- case "dynamic": {
1092
+ case "dynamic":
1093
+ case "dynamic-async": {
1000
1094
  if (ctx === undefined) {
1001
1095
  throw new InternalError("dynamic binding requires resolution context");
1002
1096
  }
1003
- const factoryResult = binding.factory(ctx);
1097
+ const factoryResult = runFactoryPrefix(binding, ctx, resolutionStack);
1004
1098
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1005
1099
  }
1006
- case "dynamic-async":
1007
- if (ctx === undefined) {
1008
- throw new InternalError("dynamic-async binding requires resolution context");
1009
- }
1010
- return binding.factory(ctx);
1011
1100
  case "class": {
1012
1101
  const deps = await this.#resolveDepsAsync(this.#constructorParams(binding.target), resolutionStack, branchDepth);
1013
1102
  // Accessor initializers resolve synchronously, so the branch-owned path serves them directly.
1014
- return this.#classes.instantiate(binding.target, deps, this.#classes.needsActiveContainer(binding.target) ? this.#ambientResolutionFor(resolutionStack) : undefined);
1103
+ return this.#introspector().instantiate(binding.target, deps, this.#introspector().needsActiveContainer(binding.target)
1104
+ ? this.#ambientResolutionFor(resolutionStack)
1105
+ : undefined);
1015
1106
  }
1016
1107
  case "resolved": {
1017
1108
  const deps = await this.#resolveDepsAsync(binding.deps, resolutionStack, branchDepth);
@@ -1040,7 +1131,7 @@ export class DependencyResolver {
1040
1131
  for (let index = 0; index < count; index += 1) {
1041
1132
  pending[index] = this.#resolveDepAsync(deps[index], resolutionStack, branchDepth);
1042
1133
  }
1043
- return Promise.all(pending);
1134
+ return settleInOrder(pending, identity);
1044
1135
  }
1045
1136
  #resolveDepAsync(dep, resolutionStack, branchDepth) {
1046
1137
  const options = resolveOptionsForSlot(dep);
@@ -1056,27 +1147,33 @@ export class DependencyResolver {
1056
1147
  return this.resolveAsync(dep.token, options, resolutionStack, branchDepth, singleCriterionForSlot(dep));
1057
1148
  }
1058
1149
  async resolveOptionalAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
1059
- const entry = this.#findBinding(token, options, resolutionStack, precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined));
1150
+ const path = ownPrefixOf(resolutionStack, branchDepth);
1151
+ const depth = path === resolutionStack ? branchDepth : branchDepthOf(path);
1152
+ const singleCriterion = precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined);
1153
+ let entry = this.#findBinding(token, options, path, singleCriterion);
1154
+ // Same single-evaluation contract as the sync lane: resolve what the probe found.
1155
+ if (entry !== undefined && entry.binding.kind === "alias") {
1156
+ // Follow the alias off the throwing path: a chain that ends nowhere is a miss, not an error.
1157
+ entry = this.#terminalOfAliasBinding(entry.binding, options, path, singleCriterion);
1158
+ }
1060
1159
  if (entry === undefined) {
1061
1160
  return undefined;
1062
1161
  }
1063
- // Same single-evaluation contract as the sync lane: resolve what the probe found.
1064
1162
  const { binding, owner } = entry;
1065
- if (binding.kind === "alias") {
1066
- return this.resolveAsync(token, options, resolutionStack, branchDepth);
1067
- }
1068
1163
  if (binding.scope === "singleton" && owner !== this) {
1069
- return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1164
+ return owner.#resolveBindingAsync(binding, options, path, depth, owner);
1070
1165
  }
1071
- return this.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1166
+ return this.#resolveBindingAsync(binding, options, path, depth, owner);
1072
1167
  }
1073
1168
  async resolveAllAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH) {
1074
- const candidates = this.#candidateBindings(token, options, resolutionStack);
1169
+ const path = ownPrefixOf(resolutionStack, branchDepth);
1170
+ const depth = path === resolutionStack ? branchDepth : branchDepthOf(path);
1171
+ const candidates = this.#candidateBindings(token, options, path);
1075
1172
  const pending = new Array(candidates.length);
1076
1173
  for (let index = 0; index < candidates.length; index += 1) {
1077
- pending[index] = this.#resolveCandidateAsync(candidates[index], options, resolutionStack, branchDepth);
1174
+ pending[index] = this.#resolveCandidateAsync(candidates[index], options, path, depth);
1078
1175
  }
1079
- return Promise.all(pending);
1176
+ return settleInOrder(pending, identity);
1080
1177
  }
1081
1178
  // ── Helpers ────────────────────────────────────────────────────────────────────────────────────────────────────────
1082
1179
  #allBindingsFromChain(token) {
@@ -1165,46 +1262,7 @@ export class DependencyResolver {
1165
1262
  if (options === undefined && resolutionStack.length === 0) {
1166
1263
  return ROOT_CONSTRAINT_CONTEXT;
1167
1264
  }
1168
- return buildConstraintContext(resolutionStack, options);
1169
- }
1170
- /** Selection for a multi-criterion request, over the union of the two tag indexes. */
1171
- #selectMultiTagged(token, options, resolutionStack) {
1172
- const candidates = [];
1173
- const gathered = [];
1174
- if (options.name !== undefined) {
1175
- // Read, not minted: an unminted name has no criterion, so no slot can carry it.
1176
- this.#gatherTagCandidates(token, slotNameCriterionOf(options.name), candidates, gathered);
1177
- }
1178
- this.#gatherTagCandidates(token, options.tag, candidates, gathered);
1179
- const listed = options.tags;
1180
- if (listed !== undefined) {
1181
- for (let index = 0; index < listed.length; index += 1) {
1182
- this.#gatherTagCandidates(token, listed[index], candidates, gathered);
1183
- }
1184
- }
1185
- if (candidates.length === 0) {
1186
- return undefined;
1187
- }
1188
- return selectBinding(candidates, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
1189
- }
1190
- /** One request criterion's candidates: its exact single-tag binding, plus its first-tag bucket. */
1191
- #gatherTagCandidates(token, criterion, out, gathered) {
1192
- // Distinct criteria never share a binding — a slot lives in exactly one bucket — so deduping
1193
- // by criterion covers a request repeating one across its spellings, without scanning `out`.
1194
- if (criterion === undefined || gathered.includes(criterion)) {
1195
- return;
1196
- }
1197
- gathered.push(criterion);
1198
- const single = this.#registry.getSimpleTagged(token, criterion);
1199
- if (single !== undefined) {
1200
- out.push(single);
1201
- }
1202
- const bucket = this.#registry.getMultiTagged(token, criterion);
1203
- if (bucket !== undefined) {
1204
- for (let index = 0; index < bucket.length; index += 1) {
1205
- out.push(bucket[index]);
1206
- }
1207
- }
1265
+ return new DefaultConstraintContext(resolutionStack, options);
1208
1266
  }
1209
1267
  /** The predicate half of a match, for a lane whose index has already settled the slot. */
1210
1268
  #satisfiesPredicate(binding, options, resolutionStack) {
@@ -1249,68 +1307,64 @@ export class DependencyResolver {
1249
1307
  }
1250
1308
  // Nothing this level appended is ever removed, so no level observes its own settlement.
1251
1309
  const ctx = new AsyncLevelContext(this, levelStack, undefined);
1310
+ if (branchDepth === ROOT_BRANCH) {
1311
+ this.#recentRootLevelContext = ctx;
1312
+ }
1252
1313
  try {
1253
- if (binding.kind === "dynamic-async") {
1254
- return binding.factory(ctx);
1255
- }
1256
- const factoryResult = binding.factory(ctx);
1314
+ const factoryResult = runFactoryPrefix(binding, ctx, levelStack);
1257
1315
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1258
1316
  }
1259
1317
  catch (factoryError) {
1260
1318
  return Promise.reject(factoryError);
1261
1319
  }
1262
1320
  }
1263
- // ── The cascade lane ───────────────────────────────────────────────────────────────────────────────────────────────
1321
+ // ── The async root ─────────────────────────────────────────────────────────────────────────────────────────────────
1264
1322
  /**
1265
- * Entry for a request a factory makes from inside an open synchronous cascade.
1323
+ * Entry for an options-less resolve the container starts: the root of a branch of its own.
1266
1324
  *
1267
- * @remarks A request arriving with no cascade open came out of a continuation, so its ancestors
1268
- * are on no call stack — it escapes to the branch lane.
1325
+ * @remarks A statically-visible transient graph answers from its compiled async plan; everything
1326
+ * else opens a branch over a fresh array, so every level's context keeps its own ancestors for as
1327
+ * long as the factory holds it — across an `await` included.
1269
1328
  */
1270
- resolveAsyncFromCascade(token) {
1271
- if (this.#cascadeStack.length === 0) {
1272
- return this.resolveAsyncFromContext(token, [], ROOT_BRANCH);
1273
- }
1274
- return this.#dispatchCascade(token);
1275
- }
1276
- /** Entry for a resolve the container starts, which opens the cascade rather than joining one. */
1277
1329
  resolveAsyncFromRoot(token) {
1278
- return this.#dispatchCascade(token);
1279
- }
1280
- #dispatchCascade(token) {
1281
1330
  const fastBinding = this.#registry.getFastDefault(token);
1282
1331
  if (fastBinding !== undefined) {
1283
- if ((fastBinding.kind === "dynamic-async" || fastBinding.kind === "dynamic") &&
1284
- fastBinding.scope === "transient" &&
1285
- !this.#hasAnyActivation(fastBinding)) {
1286
- return this.#resolveTransientDynamicAsyncCascade(fastBinding);
1287
- }
1288
- // A value that already exists answers here: escaping would snapshot the cascade for a resolve
1289
- // that never looks at a path.
1290
- if (this.#isPlainConstant(fastBinding)) {
1291
- return Promise.resolve(fastBinding.value);
1292
- }
1293
- if (fastBinding.scope === "singleton" && fastBinding.instance !== NO_INSTANCE) {
1294
- return Promise.resolve(fastBinding.instance);
1295
- }
1296
- const planned = this.#plannedCascadeAnswer(fastBinding);
1332
+ const planned = this.#plannedRootAnswer(fastBinding);
1297
1333
  if (planned !== null) {
1298
1334
  return planned;
1299
1335
  }
1336
+ if (fastBinding.scope === "transient" &&
1337
+ (fastBinding.kind === "dynamic" || fastBinding.kind === "dynamic-async") &&
1338
+ !this.#hasAnyActivation(fastBinding)) {
1339
+ return this.#rootFactoryAnswer(fastBinding);
1340
+ }
1300
1341
  }
1301
- // Anything else leaves the cascade lane for good, seeded with a snapshot of the ancestors it
1302
- // accumulated — so a cycle across the boundary is still on one path.
1303
- return this.resolveAsyncFromContext(token, [...this.#cascadeStack], UNOWNED_BRANCH);
1342
+ return this.resolveAsyncFromContext(token, [], ROOT_BRANCH);
1304
1343
  }
1305
1344
  /**
1306
- * A statically-visible transient graph at a true root answers from its compiled async plan.
1345
+ * A transient factory root's answer: its factory run over the one context the binding keeps.
1307
1346
  *
1308
- * @remarks Kept out of the dispatcher so its size stays inlinable. Inside an open cascade the
1309
- * graph must escape instead, so its escapes carry the live ancestors — hence the idle gate.
1347
+ * @remarks The root's path is its own frame alone and the request carries no options, so the level's
1348
+ * context is a function of the binding, built on the first resolve and reused by every later one —
1349
+ * a concurrent root reads the same path, and a descendant that outgrows the branch copies its prefix.
1310
1350
  */
1311
- #plannedCascadeAnswer(fastBinding) {
1312
- if (this.#cascadeStack.length !== 0 ||
1313
- fastBinding.scope !== "transient" ||
1351
+ #rootFactoryAnswer(binding) {
1352
+ let ctx = binding.rootContext;
1353
+ if (ctx === undefined) {
1354
+ ctx = new AsyncLevelContext(this, extendResolutionBranch([], ROOT_BRANCH, this.#getResolutionFrame(binding)), undefined);
1355
+ binding.rootContext = ctx;
1356
+ }
1357
+ try {
1358
+ const factoryResult = runFactoryPrefix(binding, ctx, ctx.ownPath);
1359
+ return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1360
+ }
1361
+ catch (factoryError) {
1362
+ return Promise.reject(factoryError);
1363
+ }
1364
+ }
1365
+ /** The compiled async plan's answer for a transient class or factory root, or `null` when there is none. */
1366
+ #plannedRootAnswer(fastBinding) {
1367
+ if (fastBinding.scope !== "transient" ||
1314
1368
  (fastBinding.kind !== "class" && fastBinding.kind !== "resolved" && fastBinding.kind !== "resolved-async")) {
1315
1369
  return null;
1316
1370
  }
@@ -1327,32 +1381,6 @@ export class DependencyResolver {
1327
1381
  return Promise.reject(planError);
1328
1382
  }
1329
1383
  }
1330
- #resolveTransientDynamicAsyncCascade(binding) {
1331
- const frame = this.#getResolutionFrame(binding);
1332
- // The request that closes a cycle is made from a factory's synchronous prefix, and synchronous
1333
- // code does not interleave — so the O(1) flag is exact path membership here, as it is for the
1334
- // sync lane. It is cleared when the factory returns its promise, not when that promise settles.
1335
- if (binding.inFlight) {
1336
- return Promise.reject(new CircularDependencyError(cycleNamesOf(this.#cascadeStack, frame.tokenName)));
1337
- }
1338
- const ctx = (this.#cascadeContext ??= new AsyncCascadeContext(this, this.#cascadeStack));
1339
- binding.inFlight = true;
1340
- this.#cascadeStack.push(frame);
1341
- try {
1342
- if (binding.kind === "dynamic-async") {
1343
- return binding.factory(ctx);
1344
- }
1345
- const factoryResult = binding.factory(ctx);
1346
- return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1347
- }
1348
- catch (factoryError) {
1349
- return Promise.reject(factoryError);
1350
- }
1351
- finally {
1352
- this.#cascadeStack.pop();
1353
- binding.inFlight = false;
1354
- }
1355
- }
1356
1384
  // A cached candidate answers here rather than re-entering the generic path: `resolveAll` pays
1357
1385
  // this per candidate, and a fan-out over cached handlers is the shape that makes it matter.
1358
1386
  #resolveCandidateSync(binding, options, resolutionStack) {
@@ -1426,7 +1454,7 @@ export class DependencyResolver {
1426
1454
  if (existing !== undefined) {
1427
1455
  return existing;
1428
1456
  }
1429
- const frame = buildResolutionFrame(tokenName(binding.token), binding.scope, binding.identifier, binding.kind, binding.slot);
1457
+ const frame = buildResolutionFrame(binding, tokenName(binding.token));
1430
1458
  binding.frame = frame;
1431
1459
  return frame;
1432
1460
  }
@@ -1436,36 +1464,27 @@ export class DependencyResolver {
1436
1464
  #acquireSyncResolutionContext(resolutionStack, options) {
1437
1465
  if (resolutionStack === this.rootStack) {
1438
1466
  const depth = resolutionStack.length;
1439
- const existing = this.#syncResolutionContextPool[depth];
1467
+ const pool = (this.#syncResolutionContextPool ??= []);
1468
+ const existing = pool[depth];
1440
1469
  if (existing !== undefined) {
1441
1470
  existing.reset(this, resolutionStack, options);
1442
1471
  return existing;
1443
1472
  }
1444
1473
  const created = new DefaultResolutionContext(this, resolutionStack, options);
1445
- this.#syncResolutionContextPool[depth] = created;
1474
+ pool[depth] = created;
1446
1475
  return created;
1447
1476
  }
1448
- return this.#acquireOffRootSyncContext(resolutionStack, options);
1449
- }
1450
- /** The cascade pair pools separately; a throwaway pair (nested resolve, async snapshot) mints per call. */
1451
- #acquireOffRootSyncContext(resolutionStack, options) {
1452
- if (resolutionStack !== this.#cascadeStack) {
1453
- return new DefaultResolutionContext(this, resolutionStack, options);
1454
- }
1455
- const depth = resolutionStack.length;
1456
- const pool = (this.#cascadeContextPool ??= []);
1457
- const existing = pool[depth];
1458
- if (existing !== undefined) {
1459
- existing.reset(this, resolutionStack, options);
1460
- return existing;
1461
- }
1462
- const created = new DefaultResolutionContext(this, resolutionStack, options);
1463
- pool[depth] = created;
1464
- return created;
1477
+ // A throwaway pair — a nested resolve, an async level's prefix — mints per call.
1478
+ return new DefaultResolutionContext(this, resolutionStack, options);
1465
1479
  }
1466
1480
  }
1467
- /** A constant whose value is its answer on every read: no own hook, and the caller has ruled out container hooks. */
1468
- // A cached singleton reads like a constant until a registry change evicts it, which also drops the memo.
1481
+ /**
1482
+ * Whether a binding answers a collection read with a fixed value: a hook-free constant, or a
1483
+ * singleton whose instance is already cached.
1484
+ *
1485
+ * @remarks A cached singleton reads like a constant until a registry change evicts it, which also
1486
+ * drops the memo.
1487
+ */
1469
1488
  function isStableCollectionMember(binding) {
1470
1489
  if (binding.kind === "alias" || binding.activationHook !== undefined) {
1471
1490
  return false;
@@ -1483,27 +1502,51 @@ function anyPredicate(bindings) {
1483
1502
  }
1484
1503
  return false;
1485
1504
  }
1486
- function buildConstraintContext(resolutionStack, options) {
1487
- return {
1488
- // Derived per read, never cached: the stack may be live, and the names must report it as it stands.
1489
- get resolutionPath() {
1490
- const names = new Array(resolutionStack.length);
1491
- for (let index = 0; index < resolutionStack.length; index += 1) {
1492
- names[index] = resolutionStack[index].tokenName;
1493
- }
1494
- return names;
1495
- },
1496
- resolutionStack,
1497
- parent: resolutionStack.at(-1),
1498
- ancestors: resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [],
1499
- currentResolveOptions: options,
1500
- };
1505
+ /**
1506
+ * Runs an async lane's factory with its binding flagged in flight for the factory's synchronous prefix.
1507
+ *
1508
+ * @remarks The flag is exact path membership only while synchronous code runs, so it is cleared when
1509
+ * the factory returns — its promise included — never when that promise settles: two branches that
1510
+ * await one binding are a diamond, not a cycle. A factory that resolves its own token from that
1511
+ * prefix is caught before it runs again, on the branch lane as on the sync lanes, where the level's
1512
+ * own flag already covers the whole call.
1513
+ */
1514
+ function runFactoryPrefix(binding, ctx, levelStack) {
1515
+ if (binding.inFlight) {
1516
+ // The level's own frame is the last one, and the path an error names ends where the cycle closed.
1517
+ throw new CircularDependencyError(cycleNamesOf(levelStack.slice(0, -1), tokenName(binding.token)));
1518
+ }
1519
+ binding.inFlight = true;
1520
+ try {
1521
+ return binding.factory(ctx);
1522
+ }
1523
+ finally {
1524
+ binding.inFlight = false;
1525
+ }
1526
+ }
1527
+ /**
1528
+ * The frames a level may read as its own path: the array itself while nothing has grown it past the
1529
+ * level's depth, else a copy of the level's prefix.
1530
+ *
1531
+ * @remarks Siblings start concurrently on one branch and the first appends in place, so a later
1532
+ * sibling that read the whole array would hand a `when()` predicate the first sibling's frame as its
1533
+ * parent. The copy is the one the branch lane would take for that sibling anyway, and it owns itself.
1534
+ */
1535
+ function ownPrefixOf(resolutionStack, branchDepth) {
1536
+ if (branchDepth === UNOWNED_BRANCH || resolutionStack.length === branchDepth) {
1537
+ return resolutionStack;
1538
+ }
1539
+ return resolutionStack.slice(0, branchDepth);
1501
1540
  }
1502
1541
  /** The async-resolution failure for a binding reached on a sync path, naming what to await instead. */
1503
1542
  function asyncResolutionErrorFor(binding, resolutionStack) {
1504
1543
  const sourceName = tokenName(binding.token);
1505
1544
  return new AsyncResolutionError(resolutionStack[0]?.tokenName ?? sourceName, sourceName);
1506
1545
  }
1546
+ /** The settled values of a fan-out, as they are. */
1547
+ function identity(values) {
1548
+ return values;
1549
+ }
1507
1550
  /** Only a factory is handed the resolution context; everything else gets its deps directly. */
1508
1551
  function requiresResolutionContext(binding) {
1509
1552
  return binding.kind === "dynamic" || binding.kind === "dynamic-async";