@codefast/di 0.5.0-canary.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +387 -0
  2. package/README.md +3 -1
  3. package/dist/binding.d.ts +41 -22
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +11 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/container/binding-builders.d.ts.map +1 -1
  8. package/dist/container/binding-builders.js +8 -5
  9. package/dist/container/binding-builders.js.map +1 -1
  10. package/dist/container/container.d.ts.map +1 -1
  11. package/dist/container/container.js +67 -110
  12. package/dist/container/container.js.map +1 -1
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +13 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/introspection/inspector.d.ts +0 -1
  22. package/dist/introspection/inspector.d.ts.map +1 -1
  23. package/dist/introspection/inspector.js +2 -7
  24. package/dist/introspection/inspector.js.map +1 -1
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +46 -33
  27. package/dist/registry.js.map +1 -1
  28. package/dist/resolution/activation-need.d.ts +4 -2
  29. package/dist/resolution/activation-need.d.ts.map +1 -1
  30. package/dist/resolution/activation-need.js +15 -11
  31. package/dist/resolution/activation-need.js.map +1 -1
  32. package/dist/resolution/binding-lookup-cache.d.ts +1 -1
  33. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -1
  34. package/dist/resolution/binding-lookup-cache.js +18 -2
  35. package/dist/resolution/binding-lookup-cache.js.map +1 -1
  36. package/dist/resolution/binding-scope.d.ts +5 -2
  37. package/dist/resolution/binding-scope.d.ts.map +1 -1
  38. package/dist/resolution/binding-scope.js +6 -17
  39. package/dist/resolution/binding-scope.js.map +1 -1
  40. package/dist/resolution/binding-select.d.ts +8 -1
  41. package/dist/resolution/binding-select.d.ts.map +1 -1
  42. package/dist/resolution/binding-select.js +13 -32
  43. package/dist/resolution/binding-select.js.map +1 -1
  44. package/dist/resolution/diagnostics.d.ts +2 -2
  45. package/dist/resolution/diagnostics.d.ts.map +1 -1
  46. package/dist/resolution/environment.d.ts +47 -21
  47. package/dist/resolution/environment.d.ts.map +1 -1
  48. package/dist/resolution/environment.js +134 -31
  49. package/dist/resolution/environment.js.map +1 -1
  50. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  51. package/dist/resolution/instantiation-plan.js +1 -1
  52. package/dist/resolution/instantiation-plan.js.map +1 -1
  53. package/dist/resolution/lifecycle.d.ts.map +1 -1
  54. package/dist/resolution/lifecycle.js +46 -53
  55. package/dist/resolution/lifecycle.js.map +1 -1
  56. package/dist/resolution/resolution-path.d.ts +82 -6
  57. package/dist/resolution/resolution-path.d.ts.map +1 -1
  58. package/dist/resolution/resolution-path.js +66 -8
  59. package/dist/resolution/resolution-path.js.map +1 -1
  60. package/dist/resolution/resolve-options.d.ts +41 -4
  61. package/dist/resolution/resolve-options.d.ts.map +1 -1
  62. package/dist/resolution/resolve-options.js +25 -1
  63. package/dist/resolution/resolve-options.js.map +1 -1
  64. package/dist/resolution/resolver.d.ts +29 -11
  65. package/dist/resolution/resolver.d.ts.map +1 -1
  66. package/dist/resolution/resolver.js +417 -564
  67. package/dist/resolution/resolver.js.map +1 -1
  68. package/dist/resolution/scope.d.ts +4 -0
  69. package/dist/resolution/scope.d.ts.map +1 -1
  70. package/dist/resolution/scope.js +8 -0
  71. package/dist/resolution/scope.js.map +1 -1
  72. package/package.json +5 -11
  73. package/src/binding.ts +46 -23
  74. package/src/container/binding-builders.ts +8 -5
  75. package/src/container/container.ts +72 -119
  76. package/src/errors.ts +17 -0
  77. package/src/index.ts +3 -1
  78. package/src/introspection/inspector.ts +2 -8
  79. package/src/registry.ts +52 -35
  80. package/src/resolution/activation-need.ts +18 -14
  81. package/src/resolution/binding-lookup-cache.ts +18 -2
  82. package/src/resolution/binding-scope.ts +6 -17
  83. package/src/resolution/binding-select.ts +14 -35
  84. package/src/resolution/diagnostics.ts +2 -2
  85. package/src/resolution/environment.ts +181 -35
  86. package/src/resolution/instantiation-plan.ts +5 -5
  87. package/src/resolution/lifecycle.ts +55 -53
  88. package/src/resolution/resolution-path.ts +119 -25
  89. package/src/resolution/resolve-options.ts +51 -4
  90. package/src/resolution/resolver.ts +582 -785
  91. package/src/resolution/scope.ts +10 -0
@@ -27,6 +27,8 @@ import { effectiveBindingScope } from "#/resolution/binding-scope";
27
27
  import type { ResolutionDiagnostics } from "#/resolution/diagnostics";
28
28
  import { RESOLUTION_DIAGNOSTICS } from "#/resolution/diagnostics";
29
29
  import { LifecycleManager } from "#/resolution/lifecycle";
30
+ import { ROOT_BRANCH } from "#/resolution/resolution-path";
31
+ import type { DependencySlot } from "#/resolution/resolve-options";
30
32
  import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#/resolution/resolve-options";
31
33
  import { DependencyResolver } from "#/resolution/resolver";
32
34
  import { ScopeManager } from "#/resolution/scope";
@@ -156,7 +158,7 @@ class DefaultContainer implements Container {
156
158
  if (this.#lifecycle.isBuilt) {
157
159
  builtSubsystems.push("lifecycle.activationHooks");
158
160
  }
159
- return { ...this.#resolver.describeCaches(), builtSubsystems };
161
+ return { ...this.#resolver.describeCaches(), scopedInstanceCount: this.#scope.scopedCount, builtSubsystems };
160
162
  }
161
163
 
162
164
  #initResolver(): void {
@@ -235,7 +237,7 @@ class DefaultContainer implements Container {
235
237
  return this.#drainSingletons(this.#registry.removeByToken(tokenOrId));
236
238
  }
237
239
 
238
- /** Drain singleton scope entries for a set of already-removed bindings. */
240
+ /** Drain scope entries for already-removed bindings; only singletons yield deactivation pairs. */
239
241
  #drainSingletons(bindings: ReadonlyArray<Binding>): Array<[Binding, unknown]> {
240
242
  const pairs: Array<[Binding, unknown]> = [];
241
243
  for (const binding of bindings) {
@@ -243,6 +245,7 @@ class DefaultContainer implements Container {
243
245
  pairs.push([binding, binding.instance]);
244
246
  this.#scope.deleteSingleton(binding);
245
247
  }
248
+ this.#scope.deleteScoped(binding.id);
246
249
  }
247
250
  return pairs;
248
251
  }
@@ -295,10 +298,10 @@ class DefaultContainer implements Container {
295
298
  this.#loadSyncModules(modules);
296
299
  }
297
300
 
298
- #loadSyncModules(modules: Array<SyncModule>): void {
299
- // Collect all modules in topological order (dedup by identity)
300
- const toLoad = this.#collectModuleDeps(modules);
301
- for (const module of toLoad) {
301
+ // Imports nested inside a module's setup re-enter here through the builder, so a module listed
302
+ // twice in one call is deduped by identity and the rest is ref-counting.
303
+ #loadSyncModules(modules: ReadonlyArray<SyncModule | AsyncModule>): void {
304
+ for (const module of new Set(modules)) {
302
305
  if (!isSyncModule(module)) {
303
306
  throw new AsyncModuleLoadError(module.name);
304
307
  }
@@ -315,26 +318,6 @@ class DefaultContainer implements Container {
315
318
  }
316
319
  }
317
320
 
318
- #collectModuleDeps(modules: Array<SyncModule | AsyncModule>): Array<SyncModule | AsyncModule> {
319
- const seen = new Set<object>();
320
- const result: Array<SyncModule | AsyncModule> = [];
321
-
322
- const visit = (module: SyncModule | AsyncModule): void => {
323
- const moduleRef = module as object;
324
- if (seen.has(moduleRef)) {
325
- return;
326
- }
327
- seen.add(moduleRef);
328
- // We'll collect deps during setup via the builder's import()
329
- result.push(module);
330
- };
331
-
332
- for (const module of modules) {
333
- visit(module);
334
- }
335
- return result;
336
- }
337
-
338
321
  async loadAsync(...modules: Array<SyncModule | AsyncModule>): Promise<void> {
339
322
  this.#assertNotDisposed();
340
323
  for (const module of modules) {
@@ -411,6 +394,7 @@ class DefaultContainer implements Container {
411
394
  pairs.push([binding, binding.instance]);
412
395
  this.#scope.deleteSingleton(binding);
413
396
  }
397
+ this.#scope.deleteScoped(binding.id);
414
398
  }
415
399
  }
416
400
  return pairs;
@@ -480,23 +464,33 @@ class DefaultContainer implements Container {
480
464
 
481
465
  resolve<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value {
482
466
  this.#assertNotDisposed();
467
+ const rootStack = this.#resolver.rootStack;
468
+ // A resolve already holding the shared pair means this one is nested; it mints its own.
469
+ if (rootStack.length !== 0) {
470
+ return options === undefined
471
+ ? this.#resolver.resolveFromContext(token, [], [])
472
+ : this.#resolver.resolve(token, options, [], []);
473
+ }
483
474
  if (options === undefined) {
484
- return this.#resolver.resolveFromContext(token, [], []);
475
+ return this.#resolver.resolveFromContext(token, this.#resolver.rootPath, rootStack);
485
476
  }
486
- return this.#resolver.resolve(token, options, [], []);
477
+ return this.#resolver.resolve(token, options, this.#resolver.rootPath, rootStack);
487
478
  }
488
479
 
489
480
  resolveAsync<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Promise<Value> {
490
481
  this.#assertNotDisposed();
491
482
  if (options === undefined) {
492
- return this.#resolver.resolveAsyncFromContext(token, [], []);
483
+ return this.#resolver.resolveAsyncFromRoot(token) as Promise<Value>;
493
484
  }
494
- return this.#resolver.resolveAsync(token, options, [], []);
485
+ return this.#resolver.resolveAsync(token, options, [], [], ROOT_BRANCH);
495
486
  }
496
487
 
497
488
  resolveOptional<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value | undefined {
498
489
  this.#assertNotDisposed();
499
- return this.#resolver.resolveOptional(token, options, [], []);
490
+ const rootStack = this.#resolver.rootStack;
491
+ return rootStack.length === 0
492
+ ? this.#resolver.resolveOptional(token, options, this.#resolver.rootPath, rootStack)
493
+ : this.#resolver.resolveOptional(token, options, [], []);
500
494
  }
501
495
 
502
496
  resolveOptionalAsync<const Value>(
@@ -504,12 +498,15 @@ class DefaultContainer implements Container {
504
498
  options?: ResolveOptions,
505
499
  ): Promise<Value | undefined> {
506
500
  this.#assertNotDisposed();
507
- return this.#resolver.resolveOptionalAsync(token, options, [], []);
501
+ return this.#resolver.resolveOptionalAsync(token, options, [], [], ROOT_BRANCH);
508
502
  }
509
503
 
510
504
  resolveAll<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Array<Value> {
511
505
  this.#assertNotDisposed();
512
- return this.#resolver.resolveAll(token, options, [], []);
506
+ const rootStack = this.#resolver.rootStack;
507
+ return rootStack.length === 0
508
+ ? this.#resolver.resolveAll(token, options, this.#resolver.rootPath, rootStack)
509
+ : this.#resolver.resolveAll(token, options, [], []);
513
510
  }
514
511
 
515
512
  resolveAllAsync<const Value>(
@@ -517,7 +514,7 @@ class DefaultContainer implements Container {
517
514
  options?: ResolveOptions,
518
515
  ): Promise<Array<Value>> {
519
516
  this.#assertNotDisposed();
520
- return this.#resolver.resolveAllAsync(token, options, [], []);
517
+ return this.#resolver.resolveAllAsync(token, options, [], [], ROOT_BRANCH);
521
518
  }
522
519
 
523
520
  // ── Child ─────────────────────────────────────────────────────────────────
@@ -620,10 +617,7 @@ class DefaultContainer implements Container {
620
617
  for (const edge of this.#collectStaticDependencyEdges(current, reader)) {
621
618
  const { terminal, depTokenName } = edge;
622
619
  const depScope = this.#validationScopeFromTerminal(terminal);
623
- if (depScope === "opaque") {
624
- continue;
625
- }
626
- if (depScope === "scoped" || depScope === "transient") {
620
+ if (depScope !== "singleton") {
627
621
  throw new ScopeViolationError({
628
622
  consumerToken: rootName,
629
623
  consumerScope: "singleton",
@@ -641,27 +635,18 @@ class DefaultContainer implements Container {
641
635
  dfs(root, [rootName], new Set());
642
636
  }
643
637
 
644
- #validationScopeFromTerminal(terminal: Binding): BindingScope | "opaque" {
645
- switch (terminal.kind) {
646
- case "constant":
647
- return "singleton";
648
- // A factory's *body* is not statically analyzable, but the scope it was bound with is
649
- // declared like any other — so the captive-dependency check applies. The DFS below still
650
- // refuses to descend into the factory; only this edge is judged.
651
- case "dynamic":
652
- case "dynamic-async":
653
- return terminal.scope;
654
- case "class":
655
- case "resolved":
656
- case "resolved-async":
657
- return terminal.scope;
658
- case "alias":
659
- throw new InternalError("validate: expected terminal binding after alias resolution");
660
- default: {
661
- const exhaustive: never = terminal;
662
- return exhaustive;
663
- }
638
+ /**
639
+ * The scope this edge is judged against.
640
+ *
641
+ * @remarks A factory's *body* is not statically analyzable, but the scope it was bound with is
642
+ * declared like any other — so the captive-dependency check applies to it too. The DFS still
643
+ * refuses to descend into a factory; only the edge is judged.
644
+ */
645
+ #validationScopeFromTerminal(terminal: Binding): BindingScope {
646
+ if (terminal.kind === "alias") {
647
+ throw new InternalError("validate: expected terminal binding after alias resolution");
664
648
  }
649
+ return terminal.scope;
665
650
  }
666
651
 
667
652
  #followAliasChainToTerminal(binding: Binding, options: ResolveOptions | undefined): Binding | undefined {
@@ -685,75 +670,43 @@ class DefaultContainer implements Container {
685
670
  return current;
686
671
  }
687
672
 
673
+ /** What one dependency could resolve to: every candidate for `injectAll`, else at most one. */
674
+ #peekDependencyCandidates(dep: DependencySlot, options: ResolveOptions | undefined): ReadonlyArray<Binding> {
675
+ if (dep.multi) {
676
+ return this.#resolver.peekCandidateBindingsForValidate(dep.token, options);
677
+ }
678
+ const found = this.#resolver.peekBindingForValidate(dep.token, options);
679
+ return found === undefined ? [] : [found.binding];
680
+ }
681
+
682
+ /** What a binding declares up front — a class's params, a factory's descriptors, else nothing. */
683
+ #staticDependencies(binding: Binding, reader: MetadataReader): ReadonlyArray<DependencySlot> {
684
+ if (binding.kind === "class") {
685
+ return reader.getConstructorMetadata(binding.target as Constructor)?.params ?? [];
686
+ }
687
+ if (binding.kind === "resolved" || binding.kind === "resolved-async") {
688
+ return binding.deps;
689
+ }
690
+ return [];
691
+ }
692
+
688
693
  #collectStaticDependencyEdges(
689
694
  binding: Binding,
690
695
  reader: MetadataReader,
691
696
  ): Array<{ terminal: Binding; depTokenName: string }> {
692
697
  const edges: Array<{ terminal: Binding; depTokenName: string }> = [];
693
698
 
694
- const pushTerminal = (terminal: Binding | undefined, displayName: string): void => {
695
- if (terminal === undefined) {
696
- return;
697
- }
698
- edges.push({ terminal, depTokenName: displayName });
699
- };
700
-
701
- if (binding.kind === "class") {
702
- const meta = reader.getConstructorMetadata(binding.target as Constructor);
703
- if (meta === undefined) {
704
- return edges;
705
- }
706
- for (const param of meta.params) {
707
- const paramOptions = injectionSlotToResolveOptions(param);
708
- if (param.optional) {
709
- continue;
710
- }
711
- const tokenRef = param.token;
712
- if (param.multi) {
713
- const candidates = this.#resolver.peekCandidateBindingsForValidate(tokenRef, paramOptions);
714
- for (const cand of candidates) {
715
- const term = this.#followAliasChainToTerminal(cand, paramOptions);
716
- pushTerminal(term, term !== undefined ? tokenName(term.token as Token<unknown>) : "");
717
- }
718
- continue;
719
- }
720
- const found =
721
- paramOptions === undefined
722
- ? this.#resolver.peekBindingForValidate(tokenRef, undefined)
723
- : this.#resolver.peekBindingForValidate(tokenRef, paramOptions);
724
- if (found === undefined) {
725
- continue;
726
- }
727
- const term = this.#followAliasChainToTerminal(found.binding, paramOptions);
728
- pushTerminal(term, term !== undefined ? tokenName(term.token as Token<unknown>) : "");
699
+ for (const dep of this.#staticDependencies(binding, reader)) {
700
+ // An optional dependency imposes no scope constraint: it may legitimately be absent.
701
+ if (dep.optional) {
702
+ continue;
729
703
  }
730
- return edges;
731
- }
732
-
733
- if (binding.kind === "resolved" || binding.kind === "resolved-async") {
734
- for (const dep of binding.deps) {
735
- const depOptions = injectionSlotToResolveOptions(dep);
736
- if (dep.optional) {
737
- continue;
738
- }
739
- const tokenRef = dep.token as Token<unknown> | Constructor;
740
- if (dep.multi) {
741
- const candidates = this.#resolver.peekCandidateBindingsForValidate(tokenRef, depOptions);
742
- for (const cand of candidates) {
743
- const term = this.#followAliasChainToTerminal(cand, depOptions);
744
- pushTerminal(term, term !== undefined ? tokenName(term.token as Token<unknown>) : "");
745
- }
746
- continue;
747
- }
748
- const found =
749
- depOptions === undefined
750
- ? this.#resolver.peekBindingForValidate(tokenRef, undefined)
751
- : this.#resolver.peekBindingForValidate(tokenRef, depOptions);
752
- if (found === undefined) {
753
- continue;
704
+ const depOptions = injectionSlotToResolveOptions(dep);
705
+ for (const candidate of this.#peekDependencyCandidates(dep, depOptions)) {
706
+ const terminal = this.#followAliasChainToTerminal(candidate, depOptions);
707
+ if (terminal !== undefined) {
708
+ edges.push({ terminal, depTokenName: tokenName(terminal.token as Token<unknown>) });
754
709
  }
755
- const term = this.#followAliasChainToTerminal(found.binding, depOptions);
756
- pushTerminal(term, term !== undefined ? tokenName(term.token as Token<unknown>) : "");
757
710
  }
758
711
  }
759
712
 
package/src/errors.ts CHANGED
@@ -248,6 +248,23 @@ export class RebindUnboundTokenError extends DiError {
248
248
  }
249
249
  }
250
250
 
251
+ /**
252
+ * `toSelf()` on a token that is not a class, so there is nothing to construct.
253
+ *
254
+ * @since 0.5.0-canary.9
255
+ */
256
+ export class SelfBindingRequiresClassError extends DiError {
257
+ readonly code = "SELF_BINDING_REQUIRES_CLASS";
258
+ readonly tokenName: string;
259
+
260
+ constructor(tokenName: string) {
261
+ super(
262
+ `toSelf() needs the token to be the class it constructs, and '${tokenName}' is not a class. Use .to(SomeClass) to name the implementation, or bind the class itself with container.bind(SomeClass).toSelf().`,
263
+ );
264
+ this.tokenName = tokenName;
265
+ }
266
+ }
267
+
251
268
  /**
252
269
  * @since 0.3.16-canary.0
253
270
  */
package/src/index.ts CHANGED
@@ -36,7 +36,8 @@ export type {
36
36
  export { Container } from "#/container/container";
37
37
  export type { Container as ContainerInterface, ContainerStatic } from "#/container/container";
38
38
 
39
- export { effectiveBindingScope } from "#/resolution/binding-scope";
39
+ // `effectiveBindingScope` is deliberately absent: it reads a `Binding`, which is internal, and no
40
+ // public API hands one out. `BindingSnapshot.scope` and `GraphNode.scope` are the public answers.
40
41
  export { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#/resolution/resolve-options";
41
42
 
42
43
  // Introspection types
@@ -96,6 +97,7 @@ export {
96
97
  NoMatchingBindingError,
97
98
  RebindUnboundTokenError,
98
99
  ScopeViolationError,
100
+ SelfBindingRequiresClassError,
99
101
  SyncDisposalNotSupportedError,
100
102
  TokenNotBoundError,
101
103
  } from "#/errors";
@@ -60,9 +60,8 @@ export class Inspector {
60
60
  }
61
61
 
62
62
  inspect(): ContainerSnapshot {
63
- const snapshots = this.allBindingSnapshots();
64
63
  return {
65
- ownBindings: snapshots,
64
+ ownBindings: this.#registry.allBindings().map((binding) => this.#toSnapshot(binding)),
66
65
  cachedSingletonCount: this.#scope.cachedSingletons().length,
67
66
  hasParent: this.#hasParent,
68
67
  isDisposed: this.#isDisposed(),
@@ -109,12 +108,7 @@ export class Inspector {
109
108
  };
110
109
  }
111
110
 
112
- private allBindingSnapshots(): ReadonlyArray<BindingSnapshot> {
113
- return this.#registry.allBindings().map((binding) => this.#toSnapshot(binding));
114
- }
115
-
116
111
  #toSnapshot(binding: Binding): BindingSnapshot {
117
- const scope = effectiveBindingScope(binding);
118
112
  const slot: BindingSnapshot["slot"] =
119
113
  binding.slot.name !== undefined
120
114
  ? { name: binding.slot.name, tags: binding.slot.tags }
@@ -122,7 +116,7 @@ export class Inspector {
122
116
  return {
123
117
  tokenName: tokenName(binding.token),
124
118
  kind: binding.kind,
125
- scope,
119
+ scope: effectiveBindingScope(binding),
126
120
  slot,
127
121
  id: binding.id,
128
122
  };
package/src/registry.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Binding } from "#/binding";
2
2
  import { bindingSlotEquals, bindingSlotToString } from "#/binding";
3
3
  import type { Token } from "#/token";
4
- import type { BindingIdentifier, Constructor, DependencyKey } from "#/types";
4
+ import type { BindingIdentifier, BindingTag, Constructor, DependencyKey } from "#/types";
5
5
 
6
6
  /**
7
7
  * @since 0.3.16-canary.0
@@ -43,14 +43,15 @@ export class BindingRegistry {
43
43
  add(binding: Binding): Binding | undefined {
44
44
  this.#version += 1;
45
45
  const key = binding.token as DependencyKey;
46
- // ✓ TS6.0: Map.getOrInsert (ES2025) replaces the manual get+check+set upsert
46
+ // Eager, not computed: a bind is usually the token's first, so the fallback is usually the
47
+ // one that gets stored — and the computed form would add a call to allocating it anyway.
47
48
  const bindingsForToken = this.#bindings.getOrInsert(key, []);
48
49
 
49
50
  // Only apply last-wins for slot-based bindings (not predicate-only)
50
51
  let displacedBinding: Binding | undefined;
51
- if (!this.#isPurePredicateBinding(binding)) {
52
+ if (!isPurePredicateBinding(binding)) {
52
53
  const existingIndex = bindingsForToken.findIndex(
53
- (candidate) => !this.#isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, binding.slot),
54
+ (candidate) => !isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, binding.slot),
54
55
  );
55
56
  if (existingIndex !== -1) {
56
57
  displacedBinding = bindingsForToken[existingIndex]!;
@@ -173,22 +174,22 @@ export class BindingRegistry {
173
174
  }
174
175
 
175
176
  #indexSimpleTaggedBinding(tokenKey: DependencyKey, binding: Binding): void {
176
- const slot = binding.slot;
177
- if (slot.name !== undefined || slot.tags.length !== 1 || binding.predicate !== undefined) {
177
+ const tag = simpleTagOf(binding);
178
+ if (tag === undefined) {
178
179
  return;
179
180
  }
180
- const [tagKey, tagValue] = slot.tags[0]!;
181
+ const [tagKey, tagValue] = tag;
181
182
  const byTagKey = (this.#simpleTagged ??= new Map()).getOrInsert(tokenKey, new Map<string, Map<unknown, Binding>>());
182
183
  const byTagValue = byTagKey.getOrInsert(tagKey, new Map<unknown, Binding>());
183
184
  byTagValue.set(tagValue, binding);
184
185
  }
185
186
 
186
187
  #deindexSimpleTaggedBinding(tokenKey: DependencyKey, binding: Binding): void {
187
- const slot = binding.slot;
188
- if (slot.name !== undefined || slot.tags.length !== 1 || binding.predicate !== undefined) {
188
+ const tag = simpleTagOf(binding);
189
+ if (tag === undefined) {
189
190
  return;
190
191
  }
191
- const [tagKey, tagValue] = slot.tags[0]!;
192
+ const [tagKey, tagValue] = tag;
192
193
  const byTagKey = this.#simpleTagged?.get(tokenKey);
193
194
  if (byTagKey === undefined) {
194
195
  return;
@@ -209,35 +210,26 @@ export class BindingRegistry {
209
210
  }
210
211
  }
211
212
 
212
- #isPurePredicateBinding(binding: Binding): boolean {
213
- const slot = binding.slot;
214
- const hasPredicate = binding.predicate !== undefined;
215
- const hasConstraint = slot.name !== undefined || slot.tags.length > 0;
216
- // Pure predicate = has predicate but no slot constraint (name/tags)
217
- return hasPredicate && !hasConstraint;
218
- }
219
-
220
213
  #indexSimpleNamedBinding(tokenKey: DependencyKey, binding: Binding): void {
221
- const slot = binding.slot;
222
- if (slot.name === undefined || slot.tags.length > 0) {
214
+ const name = simpleNameOf(binding);
215
+ if (name === undefined) {
223
216
  return;
224
217
  }
225
218
  const bindingsByName = (this.#simpleNamed ??= new Map()).getOrInsert(tokenKey, new Map<string, Binding>());
226
- bindingsByName.set(slot.name, binding);
219
+ bindingsByName.set(name, binding);
227
220
  }
228
221
 
229
222
  #deindexSimpleNamedBinding(tokenKey: DependencyKey, binding: Binding): void {
230
- const slot = binding.slot;
231
- if (slot.name === undefined || slot.tags.length > 0) {
223
+ const name = simpleNameOf(binding);
224
+ if (name === undefined) {
232
225
  return;
233
226
  }
234
227
  const bindingsByName = this.#simpleNamed?.get(tokenKey);
235
228
  if (bindingsByName === undefined) {
236
229
  return;
237
230
  }
238
- const currentBinding = bindingsByName.get(slot.name);
239
- if (currentBinding?.id === binding.id) {
240
- bindingsByName.delete(slot.name);
231
+ if (bindingsByName.get(name)?.id === binding.id) {
232
+ bindingsByName.delete(name);
241
233
  if (bindingsByName.size === 0) {
242
234
  this.#simpleNamed!.delete(tokenKey);
243
235
  }
@@ -246,20 +238,45 @@ export class BindingRegistry {
246
238
 
247
239
  #refreshFastDefaultForToken(tokenKey: DependencyKey): void {
248
240
  const bindingsForToken = this.#bindings.get(tokenKey);
249
- if (bindingsForToken === undefined || bindingsForToken.length !== 1) {
250
- this.#fastDefault.delete(tokenKey);
251
- return;
252
- }
253
- const onlyBinding = bindingsForToken[0]!;
254
- const isDefaultSlot = onlyBinding.slot.name === undefined && onlyBinding.slot.tags.length === 0;
255
- if (!isDefaultSlot || onlyBinding.predicate !== undefined) {
256
- this.#fastDefault.delete(tokenKey);
241
+ const onlyBinding = bindingsForToken?.length === 1 ? bindingsForToken[0]! : undefined;
242
+ if (onlyBinding !== undefined && isDefaultSlotBinding(onlyBinding)) {
243
+ this.#fastDefault.set(tokenKey, onlyBinding);
257
244
  return;
258
245
  }
259
- this.#fastDefault.set(tokenKey, onlyBinding);
246
+ this.#fastDefault.delete(tokenKey);
260
247
  }
248
+
261
249
  /** Whether the deferred table behind `#simpleNamed` has had to be built. */
262
250
  get isBuilt(): boolean {
263
251
  return this.#simpleNamed !== undefined;
264
252
  }
265
253
  }
254
+
255
+ /** The name a binding is indexed under, or `undefined` when its slot is more than a plain name. */
256
+ function simpleNameOf(binding: Binding): string | undefined {
257
+ const { name, tags } = binding.slot;
258
+ return name !== undefined && tags.length === 0 ? name : undefined;
259
+ }
260
+
261
+ /**
262
+ * The tag a binding is indexed under, or `undefined` when its slot is more than one plain tag.
263
+ *
264
+ * @remarks A predicate is excluded here — unlike the name index — because the tag index is read
265
+ * without a re-check, so an indexed hit must be unconditional.
266
+ */
267
+ function simpleTagOf(binding: Binding): BindingTag | undefined {
268
+ const { name, tags } = binding.slot;
269
+ return name === undefined && tags.length === 1 && binding.predicate === undefined ? tags[0] : undefined;
270
+ }
271
+
272
+ /** A binding nothing has to be matched against: the default slot, no predicate. */
273
+ function isDefaultSlotBinding(binding: Binding): boolean {
274
+ const { name, tags } = binding.slot;
275
+ return name === undefined && tags.length === 0 && binding.predicate === undefined;
276
+ }
277
+
278
+ /** A predicate with no slot constraint: last-wins does not apply to it. */
279
+ function isPurePredicateBinding(binding: Binding): boolean {
280
+ const { name, tags } = binding.slot;
281
+ return binding.predicate !== undefined && name === undefined && tags.length === 0;
282
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Per binding: does resolving it have to go through the activation pipeline?
3
3
  *
4
- * @remarks Versioned on the lifecycle manager, since `onActivation` can be registered at any time.
4
+ * @remarks Versioned on the lifecycle manager plus the own registry, since `onActivation` can be
5
+ * registered at any time and a rebind mints binding ids the memo must not keep forever.
5
6
  */
6
7
  import type { Binding } from "#/binding";
8
+ import type { BindingRegistry } from "#/registry";
7
9
  import type { ClassIntrospector } from "#/resolution/class-introspector";
8
10
  import type { LifecycleManager } from "#/resolution/lifecycle";
9
11
  import type { BindingIdentifier } from "#/types";
@@ -16,27 +18,31 @@ export class ActivationNeedCache {
16
18
  #version = -1;
17
19
  readonly #lifecycle: LifecycleManager;
18
20
  readonly #classes: ClassIntrospector;
21
+ readonly #registry: BindingRegistry;
19
22
 
20
- constructor(lifecycle: LifecycleManager, classes: ClassIntrospector) {
23
+ constructor(lifecycle: LifecycleManager, classes: ClassIntrospector, registry: BindingRegistry) {
21
24
  this.#lifecycle = lifecycle;
22
25
  this.#classes = classes;
26
+ this.#registry = registry;
23
27
  }
24
28
 
25
29
  needsActivation<const Value>(binding: Binding<Value>): boolean {
30
+ // The chain writes a binding's own hook in place with no version anything here can see, so it
31
+ // is read fresh on every call; the memo covers only container hooks and lifecycle metadata.
32
+ if (binding.kind !== "alias" && binding.onActivation !== undefined) {
33
+ return true;
34
+ }
26
35
  const lifecycleVersion = this.#lifecycle.activationVersion;
27
36
  // No hooks registered anywhere and none on the binding: only classes can still surprise us,
28
37
  // via a @postConstruct we have not looked for yet.
29
- if (
30
- lifecycleVersion === 0 &&
31
- binding.kind !== "class" &&
32
- binding.kind !== "alias" &&
33
- binding.onActivation === undefined
34
- ) {
38
+ if (lifecycleVersion === 0 && binding.kind !== "class" && binding.kind !== "alias") {
35
39
  return false;
36
40
  }
37
- if (this.#version !== lifecycleVersion) {
41
+ // The registry version evicts entries for binding ids a rebind has retired.
42
+ const version = lifecycleVersion + this.#registry.version;
43
+ if (this.#version !== version) {
38
44
  this.#needByBindingId.clear();
39
- this.#version = lifecycleVersion;
45
+ this.#version = version;
40
46
  }
41
47
  const cached = this.#needByBindingId.get(binding.id);
42
48
  if (cached !== undefined) {
@@ -64,8 +70,9 @@ export class ActivationNeedCache {
64
70
  return this.needsActivation(binding);
65
71
  }
66
72
 
73
+ // Own hooks are answered before the memo, so both computations cover the memoizable rest only.
67
74
  #classNeedsActivation<const Value>(binding: Binding<Value> & { kind: "class" }): boolean {
68
- if (this.#lifecycle.hasActivationHandlers(binding.token) || binding.onActivation !== undefined) {
75
+ if (this.#lifecycle.hasActivationHandlers(binding.token)) {
69
76
  return true;
70
77
  }
71
78
  // Unknown lifecycle metadata: activate once so the first instantiation can settle it.
@@ -73,9 +80,6 @@ export class ActivationNeedCache {
73
80
  }
74
81
 
75
82
  #nonClassNeedsActivation<const Value>(binding: Binding<Value>): boolean {
76
- if (binding.kind !== "alias" && binding.onActivation !== undefined) {
77
- return true;
78
- }
79
83
  return this.#lifecycle.hasActivationHandlers(binding.token);
80
84
  }
81
85
  }
@@ -31,9 +31,19 @@ export const ALIAS_HOP_LIMIT = 32;
31
31
  /**
32
32
  * @since 0.5.0-canary.8
33
33
  */
34
+ const newNameToEntryMap = <Owner>(): Map<string, DefaultLookupEntry<Owner> | null> => new Map();
35
+
36
+ /**
37
+ * @since 0.5.0-canary.9
38
+ */
34
39
  export class BindingLookupCache<Owner> {
35
40
  readonly #byToken = new Map<Token<unknown> | Constructor, DefaultLookupEntry<Owner> | null>();
36
41
  #version = -1;
42
+ // One entry in front of the map: the two shapes that reach here — an alias, and a token owned by
43
+ // a parent — are both resolved in a loop over the same token. `null` is a real answer, so absence
44
+ // is tracked by the token slot rather than by the entry.
45
+ #lastToken: Token<unknown> | Constructor | undefined;
46
+ #lastEntry: DefaultLookupEntry<Owner> | null = null;
37
47
  readonly #byTokenAndName = new Map<Token<unknown> | Constructor, Map<string, DefaultLookupEntry<Owner> | null>>();
38
48
  #namedVersion = -1;
39
49
 
@@ -62,12 +72,17 @@ export class BindingLookupCache<Owner> {
62
72
  if (version !== this.#version) {
63
73
  this.#byToken.clear();
64
74
  this.#version = version;
75
+ this.#lastToken = undefined;
76
+ } else if (token === this.#lastToken) {
77
+ return this.#lastEntry;
65
78
  }
66
79
  let entry = this.#byToken.get(token);
67
80
  if (entry === undefined) {
68
81
  entry = this.#foldAliases(token);
69
82
  this.#byToken.set(token, entry);
70
83
  }
84
+ this.#lastToken = token;
85
+ this.#lastEntry = entry;
71
86
  return entry;
72
87
  }
73
88
 
@@ -78,8 +93,9 @@ export class BindingLookupCache<Owner> {
78
93
  this.#byTokenAndName.clear();
79
94
  this.#namedVersion = version;
80
95
  }
81
- // ✓ TS6.0: Map.getOrInsert (ES2025)
82
- const byName = this.#byTokenAndName.getOrInsert(token, new Map<string, DefaultLookupEntry<Owner> | null>());
96
+ // Computed, not eager: this runs on every named resolve, and `getOrInsert(token, new Map())`
97
+ // would allocate a Map per call only to discard it on the hit that follows.
98
+ const byName = this.#byTokenAndName.getOrInsertComputed(token, newNameToEntryMap);
83
99
  let entry = byName.get(name);
84
100
  if (entry === undefined) {
85
101
  entry = this.#findNamedInChain(token, name);