@codefast/di 0.10.1 → 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 (55) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +69 -6
  3. package/dist/ambient/active-container.d.ts +7 -2
  4. package/dist/ambient/active-container.js +6 -1
  5. package/dist/container/binding-builders.d.ts +19 -1
  6. package/dist/container/binding-builders.js +75 -16
  7. package/dist/container/container.js +224 -47
  8. package/dist/core/binding-declaration.d.ts +120 -0
  9. package/dist/core/binding-declaration.js +186 -0
  10. package/dist/core/binding.d.ts +57 -5
  11. package/dist/core/binding.js +48 -1
  12. package/dist/core/module.d.ts +7 -4
  13. package/dist/core/module.js +17 -3
  14. package/dist/core/registry.d.ts +11 -7
  15. package/dist/core/registry.js +122 -38
  16. package/dist/core/state-epoch.d.ts +18 -1
  17. package/dist/core/state-epoch.js +17 -0
  18. package/dist/core/tag.js +1 -1
  19. package/dist/decorators/decorator-metadata.d.ts +9 -0
  20. package/dist/decorators/decorator-metadata.js +20 -0
  21. package/dist/decorators/inject.js +2 -1
  22. package/dist/decorators/injectable.js +3 -1
  23. package/dist/decorators/lifecycle-decorators.js +8 -2
  24. package/dist/errors/errors.d.ts +77 -3
  25. package/dist/errors/errors.js +93 -10
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +2 -1
  28. package/dist/injection/descriptor.js +3 -7
  29. package/dist/injection/resolve-options.js +6 -4
  30. package/dist/introspection/dependency-graph.d.ts +7 -2
  31. package/dist/introspection/dependency-graph.js +46 -23
  32. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  33. package/dist/introspection/inspector.js +6 -9
  34. package/dist/lifecycle/lifecycle-manager.js +14 -2
  35. package/dist/lifecycle/scope-manager.js +28 -10
  36. package/dist/metadata/verifying-metadata-reader.d.ts +4 -3
  37. package/dist/metadata/verifying-metadata-reader.js +28 -6
  38. package/dist/resolution/async-fan-out.d.ts +12 -0
  39. package/dist/resolution/async-fan-out.js +26 -0
  40. package/dist/resolution/cache/activation-need.d.ts +0 -1
  41. package/dist/resolution/cache/activation-need.js +11 -18
  42. package/dist/resolution/cache/binding-lookup-cache.d.ts +0 -7
  43. package/dist/resolution/cache/binding-lookup-cache.js +30 -17
  44. package/dist/resolution/cache/class-introspector.d.ts +11 -0
  45. package/dist/resolution/cache/class-introspector.js +18 -0
  46. package/dist/resolution/context.d.ts +15 -23
  47. package/dist/resolution/context.js +47 -56
  48. package/dist/resolution/path/resolution-path.d.ts +48 -13
  49. package/dist/resolution/path/resolution-path.js +89 -38
  50. package/dist/resolution/plan/instantiation-plan.js +61 -21
  51. package/dist/resolution/plan/plan-codegen.d.ts +7 -4
  52. package/dist/resolution/plan/plan-codegen.js +60 -32
  53. package/dist/resolution/resolver.d.ts +4 -5
  54. package/dist/resolution/resolver.js +288 -258
  55. 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";
1
+ import { bindingSlotToString, NO_INSTANCE } from "#core/binding";
2
+ import { slotNameCriterionOf } from "#core/tag";
3
3
  import { tokenName } from "#core/token";
4
4
  import { AsyncActivationError, AsyncResolutionError, CircularDependencyError, DisposedContainerError, InternalError, MissingMetadataError, MissingScopeContextError, NoMatchingBindingError, TokenNotBoundError, } from "#errors/errors";
5
5
  import { loneTagBesideNameOf, resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf, } from "#injection/resolve-options";
6
6
  import { SCOPED_MISS } from "#lifecycle/scope-manager";
7
+ import { settleInOrder } from "#resolution/async-fan-out";
7
8
  import { ActivationNeedCache } from "#resolution/cache/activation-need";
8
9
  import { BindingLookupCache } from "#resolution/cache/binding-lookup-cache";
9
10
  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";
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";
12
13
  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 = [];
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.
@@ -416,25 +451,24 @@ export class DependencyResolver {
416
451
  // The behaviour the plan compiler needs from this resolver — lookups, escapes, plan swaps, and the
417
452
  // accessor construction path. Built once with the compiler, so each closure is allocated once.
418
453
  #buildPlanCompilerHost() {
454
+ const classes = this.#introspector();
419
455
  return {
420
456
  hasActivationHandlers: (binding) => this.#ownerOf(binding).#lifecycle.hasActivationHandlers(binding.token),
421
- knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
422
- needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
457
+ knownPostConstruct: (target) => classes.knownPostConstruct(target),
458
+ needsActiveContainer: (target) => classes.needsActiveContainer(target),
423
459
  // A plan runs at the top level, so the lent root stack is free when it is; a plan reached
424
460
  // with the root stack held mints its own path, exactly as the interpreted lane would.
425
461
  constructWithAccessors: (binding, target, deps) => {
426
462
  const stack = this.rootStack.length === 0 ? this.rootStack : [];
427
- const frame = this.#getResolutionFrame(binding);
428
- const resolutionSet = enterResolutionPath(stack, frame);
463
+ enterSyncPath(stack, binding, this.#getResolutionFrame(binding));
429
464
  try {
430
- return this.#classes.instantiate(target, deps, this.#ambientResolutionFor(stack));
465
+ return classes.instantiate(target, deps, this.#ambientResolutionFor(stack));
431
466
  }
432
467
  finally {
433
- stack.pop();
434
- resolutionSet?.delete(frame.bindingId);
468
+ leaveSyncPath(stack, binding);
435
469
  }
436
470
  },
437
- getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
471
+ getConstructorMetadata: (target) => classes.constructorMetadata(target),
438
472
  lookupDependencyEntry: (token) => {
439
473
  const entry = this.#lookup.defaultEntry(token);
440
474
  return entry === null ? null : { binding: entry.binding };
@@ -505,6 +539,7 @@ export class DependencyResolver {
505
539
  }
506
540
  else {
507
541
  this.#asyncPlanByBindingId.clear();
542
+ this.#asyncPlanRequestedOnce?.clear();
508
543
  }
509
544
  this.#asyncPlanRegistryVersion = registryVersion;
510
545
  this.#asyncPlanActivationVersion = activationVersion;
@@ -514,6 +549,11 @@ export class DependencyResolver {
514
549
  if (cached !== undefined) {
515
550
  return cached;
516
551
  }
552
+ const requestedOnce = (this.#asyncPlanRequestedOnce ??= new Set());
553
+ if (!requestedOnce.has(binding.identifier)) {
554
+ requestedOnce.add(binding.identifier);
555
+ return null;
556
+ }
517
557
  const compiled = this.#compiler().compileAsync(binding);
518
558
  if (compiled === PLAN_RETRY) {
519
559
  // Lifecycle metadata not discovered yet — the fallback resolve discovers it; retry then.
@@ -581,7 +621,8 @@ export class DependencyResolver {
581
621
  }
582
622
  const frame = this.#getResolutionFrame(binding);
583
623
  const tokenDisplayName = frame.tokenName;
584
- 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);
585
626
  try {
586
627
  const needsActivation = owner.#activationNeed().needsActivation(binding);
587
628
  if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
@@ -609,8 +650,7 @@ export class DependencyResolver {
609
650
  return activated;
610
651
  }
611
652
  finally {
612
- resolutionStack.pop();
613
- resolutionSet?.delete(frame.bindingId);
653
+ leaveSyncPath(resolutionStack, binding);
614
654
  }
615
655
  }
616
656
  /**
@@ -652,7 +692,9 @@ export class DependencyResolver {
652
692
  throw asyncResolutionErrorFor(binding, resolutionStack);
653
693
  case "class": {
654
694
  const deps = this.#resolveDeps(this.#constructorParams(binding.target), resolutionStack);
655
- 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);
656
698
  }
657
699
  case "resolved": {
658
700
  const deps = this.#resolveDeps(binding.deps, resolutionStack);
@@ -675,11 +717,20 @@ export class DependencyResolver {
675
717
  * no parameters; anything else is a missing `@injectable()`.
676
718
  */
677
719
  #constructorParams(target) {
678
- const meta = this.#classes.constructorMetadata(target);
720
+ const meta = this.#introspector().constructorMetadata(target);
679
721
  if (meta !== undefined) {
680
722
  return meta.params;
681
723
  }
682
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
+ }
683
734
  return EMPTY_PARAM_LIST;
684
735
  }
685
736
  throw new MissingMetadataError(target.name);
@@ -727,16 +778,18 @@ export class DependencyResolver {
727
778
  return undefined;
728
779
  }
729
780
  }
730
- 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
+ }
731
789
  if (entry === undefined) {
732
790
  return undefined;
733
791
  }
734
- // Resolve the entry the probe found: re-looking the token up would evaluate every `when()`
735
- // predicate a second time, and a changed answer would throw where `undefined` was promised.
736
792
  const { binding, owner } = entry;
737
- if (binding.kind === "alias") {
738
- return this.resolve(token, options, resolutionStack);
739
- }
740
793
  if (binding.scope === "singleton" && owner !== this) {
741
794
  return owner.#resolveBinding(binding, options, resolutionStack, owner);
742
795
  }
@@ -761,7 +814,10 @@ export class DependencyResolver {
761
814
  const resolutionStack = this.rootStack;
762
815
  const memo = this.#rootCollection(token, resolutionStack);
763
816
  if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
764
- 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();
765
821
  }
766
822
  const { candidates } = memo;
767
823
  const resolved = new Array(candidates.length);
@@ -778,9 +834,9 @@ export class DependencyResolver {
778
834
  const resolutionStack = [];
779
835
  const memo = this.#rootCollection(token, resolutionStack);
780
836
  if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
781
- return Promise.resolve(memo.values);
837
+ return Promise.resolve(memo.values.slice());
782
838
  }
783
- 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) => {
784
840
  this.#settleCollectionValues(memo);
785
841
  return values;
786
842
  });
@@ -819,8 +875,7 @@ export class DependencyResolver {
819
875
  return;
820
876
  }
821
877
  }
822
- // Handed out as is, unfrozen: a frozen array iterates through a slow elements kind, so the
823
- // 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.
824
879
  entry.values = candidates.map(stableMemberValue);
825
880
  entry.activationVersion = 0;
826
881
  }
@@ -829,14 +884,37 @@ export class DependencyResolver {
829
884
  if (options !== undefined) {
830
885
  const indexed = this.#indexedCandidates(token, options, resolutionStack);
831
886
  if (indexed !== null) {
832
- return indexed;
887
+ return this.#withoutDanglingAliases(indexed, options, resolutionStack);
833
888
  }
834
889
  }
835
890
  const allBindings = this.#allBindingsFromChain(token);
836
891
  if (allBindings.length === 0) {
837
892
  return allBindings;
838
893
  }
839
- 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);
840
918
  }
841
919
  // ── Async resolve ──────────────────────────────────────────────────────────────────────────────────────────────────
842
920
  resolveAsyncFromContext(token, resolutionStack, branchDepth) {
@@ -898,11 +976,13 @@ export class DependencyResolver {
898
976
  return this.#resolveBindingAsync(binding, options, [], ROOT_BRANCH, this);
899
977
  }
900
978
  async resolveAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
901
- 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);
902
982
  if (binding.scope === "singleton" && owner !== this) {
903
- return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
983
+ return owner.#resolveBindingAsync(binding, options, path, depth, owner);
904
984
  }
905
- return this.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
985
+ return this.#resolveBindingAsync(binding, options, path, depth, owner);
906
986
  }
907
987
  async #resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner) {
908
988
  if (owner.#isPlainConstant(binding)) {
@@ -943,15 +1023,18 @@ export class DependencyResolver {
943
1023
  const needsActivation = owner.#activationNeed().needsActivation(binding);
944
1024
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
945
1025
  const resolutionCtx = new AsyncLevelContext(this, levelStack, options);
946
- if (binding.kind === "dynamic-async") {
947
- return await binding.factory(resolutionCtx);
1026
+ if (branchDepth === ROOT_BRANCH) {
1027
+ this.#recentRootLevelContext = resolutionCtx;
948
1028
  }
949
- const dynamicResult = binding.factory(resolutionCtx);
1029
+ const dynamicResult = runFactoryPrefix(binding, resolutionCtx, levelStack);
950
1030
  return dynamicResult instanceof Promise ? await dynamicResult : dynamicResult;
951
1031
  }
952
1032
  const resolutionCtx = needsActivation || requiresResolutionContext(binding)
953
1033
  ? new AsyncLevelContext(this, levelStack, options)
954
1034
  : undefined;
1035
+ if (resolutionCtx !== undefined && branchDepth === ROOT_BRANCH) {
1036
+ this.#recentRootLevelContext = resolutionCtx;
1037
+ }
955
1038
  if (scope === "singleton") {
956
1039
  // The promise is published before it settles, so concurrent callers dedup onto it.
957
1040
  const singletonPromise = this.#instantiateAndActivateAsync(binding, resolutionCtx, levelStack, levelDepth, needsActivation, owner).then((activated) => {
@@ -988,8 +1071,10 @@ export class DependencyResolver {
988
1071
  * this binding on every call, forever.
989
1072
  */
990
1073
  #mirrorPostConstructFromOwner(binding, owner) {
991
- if (owner !== this && binding.kind === "class" && this.#classes.knownPostConstruct(binding.target) === undefined) {
992
- 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);
993
1078
  }
994
1079
  }
995
1080
  async #instantiateAndActivateAsync(binding, ctx, resolutionStack, branchDepth, needsActivation, owner) {
@@ -1004,22 +1089,20 @@ export class DependencyResolver {
1004
1089
  switch (binding.kind) {
1005
1090
  case "constant":
1006
1091
  return binding.value;
1007
- case "dynamic": {
1092
+ case "dynamic":
1093
+ case "dynamic-async": {
1008
1094
  if (ctx === undefined) {
1009
1095
  throw new InternalError("dynamic binding requires resolution context");
1010
1096
  }
1011
- const factoryResult = binding.factory(ctx);
1097
+ const factoryResult = runFactoryPrefix(binding, ctx, resolutionStack);
1012
1098
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1013
1099
  }
1014
- case "dynamic-async":
1015
- if (ctx === undefined) {
1016
- throw new InternalError("dynamic-async binding requires resolution context");
1017
- }
1018
- return binding.factory(ctx);
1019
1100
  case "class": {
1020
1101
  const deps = await this.#resolveDepsAsync(this.#constructorParams(binding.target), resolutionStack, branchDepth);
1021
1102
  // Accessor initializers resolve synchronously, so the branch-owned path serves them directly.
1022
- 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);
1023
1106
  }
1024
1107
  case "resolved": {
1025
1108
  const deps = await this.#resolveDepsAsync(binding.deps, resolutionStack, branchDepth);
@@ -1048,7 +1131,7 @@ export class DependencyResolver {
1048
1131
  for (let index = 0; index < count; index += 1) {
1049
1132
  pending[index] = this.#resolveDepAsync(deps[index], resolutionStack, branchDepth);
1050
1133
  }
1051
- return Promise.all(pending);
1134
+ return settleInOrder(pending, identity);
1052
1135
  }
1053
1136
  #resolveDepAsync(dep, resolutionStack, branchDepth) {
1054
1137
  const options = resolveOptionsForSlot(dep);
@@ -1064,27 +1147,33 @@ export class DependencyResolver {
1064
1147
  return this.resolveAsync(dep.token, options, resolutionStack, branchDepth, singleCriterionForSlot(dep));
1065
1148
  }
1066
1149
  async resolveOptionalAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
1067
- 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
+ }
1068
1159
  if (entry === undefined) {
1069
1160
  return undefined;
1070
1161
  }
1071
- // Same single-evaluation contract as the sync lane: resolve what the probe found.
1072
1162
  const { binding, owner } = entry;
1073
- if (binding.kind === "alias") {
1074
- return this.resolveAsync(token, options, resolutionStack, branchDepth);
1075
- }
1076
1163
  if (binding.scope === "singleton" && owner !== this) {
1077
- return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1164
+ return owner.#resolveBindingAsync(binding, options, path, depth, owner);
1078
1165
  }
1079
- return this.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1166
+ return this.#resolveBindingAsync(binding, options, path, depth, owner);
1080
1167
  }
1081
1168
  async resolveAllAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH) {
1082
- 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);
1083
1172
  const pending = new Array(candidates.length);
1084
1173
  for (let index = 0; index < candidates.length; index += 1) {
1085
- pending[index] = this.#resolveCandidateAsync(candidates[index], options, resolutionStack, branchDepth);
1174
+ pending[index] = this.#resolveCandidateAsync(candidates[index], options, path, depth);
1086
1175
  }
1087
- return Promise.all(pending);
1176
+ return settleInOrder(pending, identity);
1088
1177
  }
1089
1178
  // ── Helpers ────────────────────────────────────────────────────────────────────────────────────────────────────────
1090
1179
  #allBindingsFromChain(token) {
@@ -1173,46 +1262,7 @@ export class DependencyResolver {
1173
1262
  if (options === undefined && resolutionStack.length === 0) {
1174
1263
  return ROOT_CONSTRAINT_CONTEXT;
1175
1264
  }
1176
- return buildConstraintContext(resolutionStack, options);
1177
- }
1178
- /** Selection for a multi-criterion request, over the union of the two tag indexes. */
1179
- #selectMultiTagged(token, options, resolutionStack) {
1180
- const candidates = [];
1181
- const gathered = [];
1182
- if (options.name !== undefined) {
1183
- // Read, not minted: an unminted name has no criterion, so no slot can carry it.
1184
- this.#gatherTagCandidates(token, slotNameCriterionOf(options.name), candidates, gathered);
1185
- }
1186
- this.#gatherTagCandidates(token, options.tag, candidates, gathered);
1187
- const listed = options.tags;
1188
- if (listed !== undefined) {
1189
- for (let index = 0; index < listed.length; index += 1) {
1190
- this.#gatherTagCandidates(token, listed[index], candidates, gathered);
1191
- }
1192
- }
1193
- if (candidates.length === 0) {
1194
- return undefined;
1195
- }
1196
- return selectBinding(candidates, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
1197
- }
1198
- /** One request criterion's candidates: its exact single-tag binding, plus its first-tag bucket. */
1199
- #gatherTagCandidates(token, criterion, out, gathered) {
1200
- // Distinct criteria never share a binding — a slot lives in exactly one bucket — so deduping
1201
- // by criterion covers a request repeating one across its spellings, without scanning `out`.
1202
- if (criterion === undefined || gathered.includes(criterion)) {
1203
- return;
1204
- }
1205
- gathered.push(criterion);
1206
- const single = this.#registry.getSimpleTagged(token, criterion);
1207
- if (single !== undefined) {
1208
- out.push(single);
1209
- }
1210
- const bucket = this.#registry.getMultiTagged(token, criterion);
1211
- if (bucket !== undefined) {
1212
- for (let index = 0; index < bucket.length; index += 1) {
1213
- out.push(bucket[index]);
1214
- }
1215
- }
1265
+ return new DefaultConstraintContext(resolutionStack, options);
1216
1266
  }
1217
1267
  /** The predicate half of a match, for a lane whose index has already settled the slot. */
1218
1268
  #satisfiesPredicate(binding, options, resolutionStack) {
@@ -1257,68 +1307,64 @@ export class DependencyResolver {
1257
1307
  }
1258
1308
  // Nothing this level appended is ever removed, so no level observes its own settlement.
1259
1309
  const ctx = new AsyncLevelContext(this, levelStack, undefined);
1310
+ if (branchDepth === ROOT_BRANCH) {
1311
+ this.#recentRootLevelContext = ctx;
1312
+ }
1260
1313
  try {
1261
- if (binding.kind === "dynamic-async") {
1262
- return binding.factory(ctx);
1263
- }
1264
- const factoryResult = binding.factory(ctx);
1314
+ const factoryResult = runFactoryPrefix(binding, ctx, levelStack);
1265
1315
  return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1266
1316
  }
1267
1317
  catch (factoryError) {
1268
1318
  return Promise.reject(factoryError);
1269
1319
  }
1270
1320
  }
1271
- // ── The cascade lane ───────────────────────────────────────────────────────────────────────────────────────────────
1321
+ // ── The async root ─────────────────────────────────────────────────────────────────────────────────────────────────
1272
1322
  /**
1273
- * 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.
1274
1324
  *
1275
- * @remarks A request arriving with no cascade open came out of a continuation, so its ancestors
1276
- * 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.
1277
1328
  */
1278
- resolveAsyncFromCascade(token) {
1279
- if (this.#cascadeStack.length === 0) {
1280
- return this.resolveAsyncFromContext(token, [], ROOT_BRANCH);
1281
- }
1282
- return this.#dispatchCascade(token);
1283
- }
1284
- /** Entry for a resolve the container starts, which opens the cascade rather than joining one. */
1285
1329
  resolveAsyncFromRoot(token) {
1286
- return this.#dispatchCascade(token);
1287
- }
1288
- #dispatchCascade(token) {
1289
1330
  const fastBinding = this.#registry.getFastDefault(token);
1290
1331
  if (fastBinding !== undefined) {
1291
- if ((fastBinding.kind === "dynamic-async" || fastBinding.kind === "dynamic") &&
1292
- fastBinding.scope === "transient" &&
1293
- !this.#hasAnyActivation(fastBinding)) {
1294
- return this.#resolveTransientDynamicAsyncCascade(fastBinding);
1295
- }
1296
- // A value that already exists answers here: escaping would snapshot the cascade for a resolve
1297
- // that never looks at a path.
1298
- if (this.#isPlainConstant(fastBinding)) {
1299
- return Promise.resolve(fastBinding.value);
1300
- }
1301
- if (fastBinding.scope === "singleton" && fastBinding.instance !== NO_INSTANCE) {
1302
- return Promise.resolve(fastBinding.instance);
1303
- }
1304
- const planned = this.#plannedCascadeAnswer(fastBinding);
1332
+ const planned = this.#plannedRootAnswer(fastBinding);
1305
1333
  if (planned !== null) {
1306
1334
  return planned;
1307
1335
  }
1336
+ if (fastBinding.scope === "transient" &&
1337
+ (fastBinding.kind === "dynamic" || fastBinding.kind === "dynamic-async") &&
1338
+ !this.#hasAnyActivation(fastBinding)) {
1339
+ return this.#rootFactoryAnswer(fastBinding);
1340
+ }
1308
1341
  }
1309
- // Anything else leaves the cascade lane for good, seeded with a snapshot of the ancestors it
1310
- // accumulated — so a cycle across the boundary is still on one path.
1311
- return this.resolveAsyncFromContext(token, [...this.#cascadeStack], UNOWNED_BRANCH);
1342
+ return this.resolveAsyncFromContext(token, [], ROOT_BRANCH);
1312
1343
  }
1313
1344
  /**
1314
- * 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.
1315
1346
  *
1316
- * @remarks Kept out of the dispatcher so its size stays inlinable. Inside an open cascade the
1317
- * 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.
1318
1350
  */
1319
- #plannedCascadeAnswer(fastBinding) {
1320
- if (this.#cascadeStack.length !== 0 ||
1321
- 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" ||
1322
1368
  (fastBinding.kind !== "class" && fastBinding.kind !== "resolved" && fastBinding.kind !== "resolved-async")) {
1323
1369
  return null;
1324
1370
  }
@@ -1335,32 +1381,6 @@ export class DependencyResolver {
1335
1381
  return Promise.reject(planError);
1336
1382
  }
1337
1383
  }
1338
- #resolveTransientDynamicAsyncCascade(binding) {
1339
- const frame = this.#getResolutionFrame(binding);
1340
- // The request that closes a cycle is made from a factory's synchronous prefix, and synchronous
1341
- // code does not interleave — so the O(1) flag is exact path membership here, as it is for the
1342
- // sync lane. It is cleared when the factory returns its promise, not when that promise settles.
1343
- if (binding.inFlight) {
1344
- return Promise.reject(new CircularDependencyError(cycleNamesOf(this.#cascadeStack, frame.tokenName)));
1345
- }
1346
- const ctx = (this.#cascadeContext ??= new AsyncCascadeContext(this, this.#cascadeStack));
1347
- binding.inFlight = true;
1348
- this.#cascadeStack.push(frame);
1349
- try {
1350
- if (binding.kind === "dynamic-async") {
1351
- return binding.factory(ctx);
1352
- }
1353
- const factoryResult = binding.factory(ctx);
1354
- return factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult);
1355
- }
1356
- catch (factoryError) {
1357
- return Promise.reject(factoryError);
1358
- }
1359
- finally {
1360
- this.#cascadeStack.pop();
1361
- binding.inFlight = false;
1362
- }
1363
- }
1364
1384
  // A cached candidate answers here rather than re-entering the generic path: `resolveAll` pays
1365
1385
  // this per candidate, and a fan-out over cached handlers is the shape that makes it matter.
1366
1386
  #resolveCandidateSync(binding, options, resolutionStack) {
@@ -1434,7 +1454,7 @@ export class DependencyResolver {
1434
1454
  if (existing !== undefined) {
1435
1455
  return existing;
1436
1456
  }
1437
- const frame = buildResolutionFrame(tokenName(binding.token), binding.scope, binding.identifier, binding.kind, binding.slot);
1457
+ const frame = buildResolutionFrame(binding, tokenName(binding.token));
1438
1458
  binding.frame = frame;
1439
1459
  return frame;
1440
1460
  }
@@ -1444,32 +1464,18 @@ export class DependencyResolver {
1444
1464
  #acquireSyncResolutionContext(resolutionStack, options) {
1445
1465
  if (resolutionStack === this.rootStack) {
1446
1466
  const depth = resolutionStack.length;
1447
- const existing = this.#syncResolutionContextPool[depth];
1467
+ const pool = (this.#syncResolutionContextPool ??= []);
1468
+ const existing = pool[depth];
1448
1469
  if (existing !== undefined) {
1449
1470
  existing.reset(this, resolutionStack, options);
1450
1471
  return existing;
1451
1472
  }
1452
1473
  const created = new DefaultResolutionContext(this, resolutionStack, options);
1453
- this.#syncResolutionContextPool[depth] = created;
1474
+ pool[depth] = created;
1454
1475
  return created;
1455
1476
  }
1456
- return this.#acquireOffRootSyncContext(resolutionStack, options);
1457
- }
1458
- /** The cascade pair pools separately; a throwaway pair (nested resolve, async snapshot) mints per call. */
1459
- #acquireOffRootSyncContext(resolutionStack, options) {
1460
- if (resolutionStack !== this.#cascadeStack) {
1461
- return new DefaultResolutionContext(this, resolutionStack, options);
1462
- }
1463
- const depth = resolutionStack.length;
1464
- const pool = (this.#cascadeContextPool ??= []);
1465
- const existing = pool[depth];
1466
- if (existing !== undefined) {
1467
- existing.reset(this, resolutionStack, options);
1468
- return existing;
1469
- }
1470
- const created = new DefaultResolutionContext(this, resolutionStack, options);
1471
- pool[depth] = created;
1472
- return created;
1477
+ // A throwaway pair — a nested resolve, an async level's prefix — mints per call.
1478
+ return new DefaultResolutionContext(this, resolutionStack, options);
1473
1479
  }
1474
1480
  }
1475
1481
  /**
@@ -1496,27 +1502,51 @@ function anyPredicate(bindings) {
1496
1502
  }
1497
1503
  return false;
1498
1504
  }
1499
- function buildConstraintContext(resolutionStack, options) {
1500
- return {
1501
- // Derived per read, never cached: the stack may be live, and the names must report it as it stands.
1502
- get resolutionPath() {
1503
- const names = new Array(resolutionStack.length);
1504
- for (let index = 0; index < resolutionStack.length; index += 1) {
1505
- names[index] = resolutionStack[index].tokenName;
1506
- }
1507
- return names;
1508
- },
1509
- resolutionStack,
1510
- parent: resolutionStack.at(-1),
1511
- ancestors: resolutionStack.length > 1 ? resolutionStack.slice(0, -1) : [],
1512
- currentResolveOptions: options,
1513
- };
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);
1514
1540
  }
1515
1541
  /** The async-resolution failure for a binding reached on a sync path, naming what to await instead. */
1516
1542
  function asyncResolutionErrorFor(binding, resolutionStack) {
1517
1543
  const sourceName = tokenName(binding.token);
1518
1544
  return new AsyncResolutionError(resolutionStack[0]?.tokenName ?? sourceName, sourceName);
1519
1545
  }
1546
+ /** The settled values of a fan-out, as they are. */
1547
+ function identity(values) {
1548
+ return values;
1549
+ }
1520
1550
  /** Only a factory is handed the resolution context; everything else gets its deps directly. */
1521
1551
  function requiresResolutionContext(binding) {
1522
1552
  return binding.kind === "dynamic" || binding.kind === "dynamic-async";