@codefast/di 0.7.1 → 0.8.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 (63) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/container/binding-builders.d.ts.map +1 -1
  3. package/dist/container/binding-builders.js +4 -5
  4. package/dist/container/binding-builders.js.map +1 -1
  5. package/dist/container/container.d.ts.map +1 -1
  6. package/dist/container/container.js +3 -4
  7. package/dist/container/container.js.map +1 -1
  8. package/dist/core/binding.d.ts +15 -2
  9. package/dist/core/binding.d.ts.map +1 -1
  10. package/dist/core/binding.js +29 -4
  11. package/dist/core/binding.js.map +1 -1
  12. package/dist/core/registry.d.ts +0 -3
  13. package/dist/core/registry.d.ts.map +1 -1
  14. package/dist/core/registry.js +14 -61
  15. package/dist/core/registry.js.map +1 -1
  16. package/dist/core/tag.d.ts +26 -0
  17. package/dist/core/tag.d.ts.map +1 -1
  18. package/dist/core/tag.js +54 -11
  19. package/dist/core/tag.js.map +1 -1
  20. package/dist/errors/errors.d.ts.map +1 -1
  21. package/dist/errors/errors.js +28 -3
  22. package/dist/errors/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/injection/resolve-options.d.ts +17 -12
  28. package/dist/injection/resolve-options.d.ts.map +1 -1
  29. package/dist/injection/resolve-options.js +69 -15
  30. package/dist/injection/resolve-options.js.map +1 -1
  31. package/dist/introspection/dependency-graph.d.ts.map +1 -1
  32. package/dist/introspection/dependency-graph.js +22 -9
  33. package/dist/introspection/dependency-graph.js.map +1 -1
  34. package/dist/resolution/cache/binding-lookup-cache.d.ts +1 -3
  35. package/dist/resolution/cache/binding-lookup-cache.d.ts.map +1 -1
  36. package/dist/resolution/cache/binding-lookup-cache.js +1 -38
  37. package/dist/resolution/cache/binding-lookup-cache.js.map +1 -1
  38. package/dist/resolution/plan/instantiation-plan.d.ts +4 -12
  39. package/dist/resolution/plan/instantiation-plan.d.ts.map +1 -1
  40. package/dist/resolution/plan/instantiation-plan.js +9 -27
  41. package/dist/resolution/plan/instantiation-plan.js.map +1 -1
  42. package/dist/resolution/resolver.d.ts +12 -5
  43. package/dist/resolution/resolver.d.ts.map +1 -1
  44. package/dist/resolution/resolver.js +106 -141
  45. package/dist/resolution/resolver.js.map +1 -1
  46. package/dist/resolution/select/binding-select.d.ts +4 -5
  47. package/dist/resolution/select/binding-select.d.ts.map +1 -1
  48. package/dist/resolution/select/binding-select.js +18 -20
  49. package/dist/resolution/select/binding-select.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/container/binding-builders.ts +10 -5
  52. package/src/container/container.ts +3 -4
  53. package/src/core/binding.ts +33 -5
  54. package/src/core/registry.ts +14 -66
  55. package/src/core/tag.ts +71 -17
  56. package/src/errors/errors.ts +28 -5
  57. package/src/index.ts +1 -1
  58. package/src/injection/resolve-options.ts +75 -18
  59. package/src/introspection/dependency-graph.ts +23 -9
  60. package/src/resolution/cache/binding-lookup-cache.ts +1 -41
  61. package/src/resolution/plan/instantiation-plan.ts +13 -38
  62. package/src/resolution/resolver.ts +120 -141
  63. package/src/resolution/select/binding-select.ts +18 -21
@@ -1,8 +1,8 @@
1
1
  import { NO_INSTANCE } from "#/core/binding";
2
- import { NO_TAG_KEYS } from "#/core/tag";
2
+ import { NO_TAG_KEYS, 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
- import { isNameOnlyOptions, resolveOptionsForSlot, singleTagOnlyOf } from "#/injection/resolve-options";
5
+ import { resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf } from "#/injection/resolve-options";
6
6
  import { SCOPED_MISS } from "#/lifecycle/scope-manager";
7
7
  import { ActivationNeedCache } from "#/resolution/cache/activation-need";
8
8
  import { BindingLookupCache } from "#/resolution/cache/binding-lookup-cache";
@@ -96,49 +96,47 @@ export class DependencyResolver {
96
96
  };
97
97
  }
98
98
  // ── Binding lookup ─────────────────────────────────────────────────────────────────────────────────────────────────
99
- #findBinding(token, options, resolutionStack) {
99
+ /**
100
+ * Finds the binding a request selects in this container, walking up to the parent on a miss.
101
+ *
102
+ * @remarks `singleCriterion` is the request's lone criterion, folded once by the caller so alias
103
+ * hops and parent walks do not re-fold it — `undefined` when the request carries none or several.
104
+ */
105
+ #findBinding(token, options, resolutionStack, singleCriterion) {
100
106
  if (options === undefined) {
101
107
  const fastDefaultBinding = this.#registry.getFastDefault(token);
102
108
  if (fastDefaultBinding !== undefined) {
103
109
  return { binding: fastDefaultBinding, owner: this };
104
110
  }
105
111
  }
106
- else if (isNameOnlyOptions(options)) {
107
- const namedBinding = this.#registry.getSimpleNamed(token, options.name);
108
- if (namedBinding !== undefined && this.#matchesBindingFast(namedBinding, options, resolutionStack)) {
109
- return { binding: namedBinding, owner: this };
112
+ else if (singleCriterion !== undefined) {
113
+ const indexed = this.#registry.getSimpleTagged(token, singleCriterion);
114
+ if (indexed !== undefined && this.#satisfiesPredicate(indexed, options, resolutionStack)) {
115
+ return { binding: indexed, owner: this };
110
116
  }
111
117
  }
112
- else {
113
- const singleTag = singleTagOnlyOf(options);
114
- if (singleTag !== undefined) {
115
- const tagged = this.#registry.getSimpleTagged(token, singleTag);
116
- if (tagged !== undefined && this.#satisfiesPredicate(tagged, options, resolutionStack)) {
117
- return { binding: tagged, owner: this };
118
- }
119
- }
120
- else if (options.name === undefined &&
121
- // A threshold switches the data structure, never the semantics: under it the generic scan
122
- // below beats walking the indexes, and both paths answer identically. Sized first, so a
123
- // small list pays one length read and nothing else.
124
- this.#registry.getAll(token).length > MULTI_TAG_INDEX_THRESHOLD &&
125
- requestedTagKeyMask(options) !== NO_TAG_KEYS) {
126
- // A name-less multi-tag request matches only name-less tagged slots, and every such slot
127
- // lives in one of the two tag indexes — so their union is the whole candidate set and the
128
- // token's full list never needs scanning. Selection still owns predicates and specificity.
129
- const selected = this.#selectMultiTagged(token, options, resolutionStack);
130
- if (selected !== undefined) {
131
- return { binding: selected, owner: this };
132
- }
133
- return this.#parent === undefined ? undefined : this.#parent.#findBinding(token, options, resolutionStack);
118
+ else if (
119
+ // A threshold switches the data structure, never the semantics: under it the generic scan
120
+ // below beats walking the indexes, and both paths answer identically. Sized first, so a
121
+ // small list pays one length read and nothing else.
122
+ this.#registry.getAll(token).length > MULTI_TAG_INDEX_THRESHOLD &&
123
+ requestedTagKeyMask(options) !== NO_TAG_KEYS) {
124
+ // A multi-criterion request matches only slots whose every criterion it carries, and every
125
+ // such slot is in the two tag indexes — their union is the whole candidate set, unscanned.
126
+ const selected = this.#selectMultiTagged(token, options, resolutionStack);
127
+ if (selected !== undefined) {
128
+ return { binding: selected, owner: this };
134
129
  }
130
+ return this.#parent === undefined
131
+ ? undefined
132
+ : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
135
133
  }
136
134
  const bindings = this.#registry.getAll(token);
137
135
  if (bindings.length > 0) {
138
136
  // A lone candidate is its own selection: matching it is the whole decision, with no
139
137
  // specificity to weigh and no ambiguity to report.
140
138
  const selected = bindings.length === 1
141
- ? this.#matchesBindingFast(bindings[0], options, resolutionStack)
139
+ ? matchesSlot(bindings[0].slot, options) && this.#satisfiesPredicate(bindings[0], options, resolutionStack)
142
140
  ? bindings[0]
143
141
  : undefined
144
142
  : selectBinding(bindings, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
@@ -147,7 +145,7 @@ export class DependencyResolver {
147
145
  }
148
146
  }
149
147
  if (this.#parent !== undefined) {
150
- return this.#parent.#findBinding(token, options, resolutionStack);
148
+ return this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
151
149
  }
152
150
  return undefined;
153
151
  }
@@ -157,10 +155,12 @@ export class DependencyResolver {
157
155
  * @remarks Alias hops are followed iteratively with exact cycle detection — a revisited alias
158
156
  * token raises {@link CircularDependencyError} instead of overflowing the call stack.
159
157
  */
160
- #requireBinding(token, options, resolutionStack) {
158
+ #requireBinding(token, options, resolutionStack, precomputedCriterion) {
159
+ // `null` is a caller's "folded: none" — only an absent precomputation re-folds.
160
+ const singleCriterion = precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined);
161
161
  let currentToken = token;
162
162
  let visitedAliasTokens;
163
- let found = this.#findBinding(currentToken, options, resolutionStack);
163
+ let found = this.#findBinding(currentToken, options, resolutionStack, singleCriterion);
164
164
  while (found !== undefined && found.binding.kind === "alias") {
165
165
  const target = found.binding.target;
166
166
  visitedAliasTokens ??= new Set([currentToken]);
@@ -169,7 +169,7 @@ export class DependencyResolver {
169
169
  }
170
170
  visitedAliasTokens.add(target);
171
171
  currentToken = target;
172
- found = this.#findBinding(currentToken, options, resolutionStack);
172
+ found = this.#findBinding(currentToken, options, resolutionStack, singleCriterion);
173
173
  }
174
174
  if (found === undefined) {
175
175
  // Thrown here rather than from a helper: the error captures this stack, and an error path is
@@ -185,7 +185,7 @@ export class DependencyResolver {
185
185
  * Binding lookup aligned with `resolve` — used by `Container.validate` without instantiating.
186
186
  */
187
187
  peekBindingForValidate(token, options) {
188
- return this.#findBinding(token, options, []);
188
+ return this.#findBinding(token, options, [], singleCriterionOnlyOf(options));
189
189
  }
190
190
  /**
191
191
  * Mirrors {@link DependencyResolver.resolveAll} candidate selection only (no instantiation).
@@ -329,22 +329,14 @@ export class DependencyResolver {
329
329
  const entry = this.#lookup.defaultEntry(token);
330
330
  return entry === null ? null : { binding: entry.binding };
331
331
  },
332
- // Exactly what #findBinding's named lane accepts, minus the half that reads a path: a predicate
333
- // is the compiler's cue to leave the selection to the runtime.
334
- lookupPathIndependentNamedEntry: (token, options) => {
335
- const entry = this.#lookup.namedEntry(token, options.name);
336
- if (entry === null || entry.binding.predicate !== undefined || !matchesSlot(entry.binding.slot, options)) {
332
+ // Exactly what #findBinding's single-criterion lane accepts, minus the half that reads a path:
333
+ // a predicate is the compiler's cue to leave the selection to the runtime.
334
+ lookupPathIndependentEntry: (token, options) => {
335
+ const singleCriterion = singleCriterionOnlyOf(options);
336
+ if (singleCriterion === undefined) {
337
337
  return null;
338
338
  }
339
- return { binding: entry.binding };
340
- },
341
- // The named rule verbatim, on the single-tag lane's memo.
342
- lookupPathIndependentTaggedEntry: (token, options) => {
343
- const singleTag = singleTagOnlyOf(options);
344
- if (singleTag === undefined) {
345
- return null;
346
- }
347
- const entry = this.#lookup.taggedEntry(token, singleTag);
339
+ const entry = this.#lookup.taggedEntry(token, singleCriterion);
348
340
  if (entry === null || entry.binding.predicate !== undefined || !matchesSlot(entry.binding.slot, options)) {
349
341
  return null;
350
342
  }
@@ -400,40 +392,28 @@ export class DependencyResolver {
400
392
  this.#asyncPlanByBindingId.set(binding.id, compiled);
401
393
  return compiled;
402
394
  }
403
- resolve(token, options, resolutionStack) {
404
- // Name-only fast lane: memoized lookup, dispatching just the shapes whose
405
- // semantics involve no resolution context (constants, cached singletons).
406
- if (options !== undefined && isNameOnlyOptions(options)) {
407
- const namedEntry = this.#lookup.namedEntry(token, options.name);
408
- if (namedEntry !== null) {
409
- const namedBinding = namedEntry.binding;
410
- if (namedEntry.owner.#isPlainConstant(namedBinding)) {
411
- return namedBinding.value;
412
- }
413
- if (namedBinding.scope === "singleton" && namedBinding.instance !== NO_INSTANCE) {
414
- return namedBinding.instance;
415
- }
416
- // Everything else keeps the full path (context, activation, guards).
417
- }
418
- }
419
- else if (options !== undefined) {
420
- // Single-tag fast lane: the named lane's tagged twin, memoizing the chain walk.
421
- const singleTag = singleTagOnlyOf(options);
422
- if (singleTag !== undefined) {
423
- const taggedEntry = this.#lookup.taggedEntry(token, singleTag);
424
- if (taggedEntry !== null) {
425
- const taggedBinding = taggedEntry.binding;
426
- if (taggedEntry.owner.#isPlainConstant(taggedBinding)) {
427
- return taggedBinding.value;
395
+ resolve(token, options, resolutionStack, precomputedCriterion) {
396
+ // Single-criterion fast lane (a lone name folds here too): memoized lookup, dispatching just
397
+ // the shapes whose semantics involve no resolution context (constants, cached singletons).
398
+ let singleCriterion;
399
+ if (options !== undefined) {
400
+ singleCriterion =
401
+ precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined);
402
+ if (singleCriterion !== undefined) {
403
+ const indexedEntry = this.#lookup.taggedEntry(token, singleCriterion);
404
+ if (indexedEntry !== null) {
405
+ const indexedBinding = indexedEntry.binding;
406
+ if (indexedEntry.owner.#isPlainConstant(indexedBinding)) {
407
+ return indexedBinding.value;
428
408
  }
429
- if (taggedBinding.scope === "singleton" && taggedBinding.instance !== NO_INSTANCE) {
430
- return taggedBinding.instance;
409
+ if (indexedBinding.scope === "singleton" && indexedBinding.instance !== NO_INSTANCE) {
410
+ return indexedBinding.instance;
431
411
  }
432
412
  // Everything else keeps the full path (context, activation, guards).
433
413
  }
434
414
  }
435
415
  }
436
- const { binding, owner } = this.#requireBinding(token, options, resolutionStack);
416
+ const { binding, owner } = this.#requireBinding(token, options, resolutionStack, singleCriterion ?? null);
437
417
  // A singleton owned by a parent resolver is resolved there, so the parent caches it.
438
418
  if (binding.scope === "singleton" && owner !== this) {
439
419
  return owner.#resolveBinding(binding, options, resolutionStack, owner);
@@ -584,15 +564,15 @@ export class DependencyResolver {
584
564
  return this.resolveAll(dep.token, options, resolutionStack);
585
565
  }
586
566
  if (dep.optional) {
587
- return this.resolveOptional(dep.token, options, resolutionStack);
567
+ return this.resolveOptional(dep.token, options, resolutionStack, singleCriterionForSlot(dep));
588
568
  }
589
569
  if (options === undefined) {
590
570
  return this.resolveFromContext(dep.token, resolutionStack);
591
571
  }
592
- return this.resolve(dep.token, options, resolutionStack);
572
+ return this.resolve(dep.token, options, resolutionStack, singleCriterionForSlot(dep));
593
573
  }
594
- resolveOptional(token, options, resolutionStack) {
595
- const entry = this.#findBinding(token, options, resolutionStack);
574
+ resolveOptional(token, options, resolutionStack, precomputedCriterion) {
575
+ const entry = this.#findBinding(token, options, resolutionStack, precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined));
596
576
  if (entry === undefined) {
597
577
  return undefined;
598
578
  }
@@ -679,8 +659,17 @@ export class DependencyResolver {
679
659
  }
680
660
  return this.#resolveBindingAsync(binding, undefined, resolutionStack, branchDepth, owner);
681
661
  }
682
- async resolveAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH) {
683
- const { binding, owner } = this.#requireBinding(token, options, resolutionStack);
662
+ /**
663
+ * Instantiates one owned binding directly, bypassing selection.
664
+ *
665
+ * @remarks Warm-up must build the binding it inspected: re-selecting by the slot's own criteria
666
+ * could pick a different candidate whose criteria are a subset of them.
667
+ */
668
+ warmBindingAsync(binding, options) {
669
+ return this.#resolveBindingAsync(binding, options, [], ROOT_BRANCH, this);
670
+ }
671
+ async resolveAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
672
+ const { binding, owner } = this.#requireBinding(token, options, resolutionStack, precomputedCriterion);
684
673
  if (binding.scope === "singleton" && owner !== this) {
685
674
  return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
686
675
  }
@@ -838,15 +827,15 @@ export class DependencyResolver {
838
827
  return this.resolveAllAsync(dep.token, options, resolutionStack, branchDepth);
839
828
  }
840
829
  if (dep.optional) {
841
- return this.resolveOptionalAsync(dep.token, options, resolutionStack, branchDepth);
830
+ return this.resolveOptionalAsync(dep.token, options, resolutionStack, branchDepth, singleCriterionForSlot(dep));
842
831
  }
843
832
  if (options === undefined) {
844
833
  return this.resolveAsyncFromContext(dep.token, resolutionStack, branchDepth);
845
834
  }
846
- return this.resolveAsync(dep.token, options, resolutionStack, branchDepth);
835
+ return this.resolveAsync(dep.token, options, resolutionStack, branchDepth, singleCriterionForSlot(dep));
847
836
  }
848
- async resolveOptionalAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH) {
849
- const entry = this.#findBinding(token, options, resolutionStack);
837
+ async resolveOptionalAsync(token, options, resolutionStack, branchDepth = UNOWNED_BRANCH, precomputedCriterion) {
838
+ const entry = this.#findBinding(token, options, resolutionStack, precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined));
850
839
  if (entry === undefined) {
851
840
  return undefined;
852
841
  }
@@ -883,56 +872,34 @@ export class DependencyResolver {
883
872
  }
884
873
  return result;
885
874
  }
886
- /** Every binding the chain's name indexes hold for one name, nearest container first. */
887
- #namedBindingsFromChain(token, name) {
888
- // A name resolves to at most one binding per registry, so a root container's answer is built
889
- // whole rather than grown — the list is sized at its allocation.
890
- const ownBinding = this.#registry.getSimpleNamed(token, name);
891
- if (this.#parent === undefined) {
892
- return ownBinding === undefined ? [] : [ownBinding];
893
- }
894
- const result = ownBinding === undefined ? [] : [ownBinding];
895
- for (let current = this.#parent; current !== undefined; current = current.#parent) {
896
- const binding = current.#registry.getSimpleNamed(token, name);
897
- if (binding !== undefined) {
898
- result.push(binding);
899
- }
900
- }
901
- return result;
902
- }
903
875
  /**
904
- * The candidates an index can name outright, or `null` when the request needs full selection.
876
+ * The candidates the index can name outright, or `null` when the request needs full selection.
905
877
  *
906
- * @remarks Kept off `#candidateBindings` so that method stays the size it was: a request neither
907
- * index serves must not pay for the two that do.
878
+ * @remarks Kept off `#candidateBindings` so that method stays the size it was: a request the
879
+ * index cannot serve must not pay for the shape it does.
908
880
  * An index has matched the slot already, but a hit may still carry a predicate, and evaluating
909
881
  * that is the selection path's job.
910
882
  */
911
883
  #indexedCandidates(token, options, resolutionStack) {
912
- if (isNameOnlyOptions(options)) {
913
- const named = this.#namedBindingsFromChain(token, options.name);
914
- return anyPredicate(named)
915
- ? selectAllBindings(named, options, this.#makeConstraintContext(resolutionStack, options))
916
- : named;
917
- }
918
- const singleTag = singleTagOnlyOf(options);
919
- if (singleTag === undefined) {
884
+ const singleCriterion = singleCriterionOnlyOf(options);
885
+ if (singleCriterion === undefined) {
920
886
  return null;
921
887
  }
922
- const tagged = this.#taggedBindingsFromChain(token, singleTag);
923
- return anyPredicate(tagged)
924
- ? selectAllBindings(tagged, options, this.#makeConstraintContext(resolutionStack, options))
925
- : tagged;
888
+ const indexed = this.#taggedBindingsFromChain(token, singleCriterion);
889
+ return anyPredicate(indexed)
890
+ ? selectAllBindings(indexed, options, this.#makeConstraintContext(resolutionStack, options))
891
+ : indexed;
926
892
  }
927
893
  /**
928
- * Every binding the chain's tag indexes hold for one tag, nearest container first.
894
+ * Every binding the chain's criterion indexes hold for one criterion, nearest container first.
929
895
  *
930
- * @remarks A request for one tag and no name matches exactly the bindings the index keys, so this
931
- * is the whole candidate set rather than a prefilter — a named or multi-tag slot cannot satisfy it.
896
+ * @remarks A request for exactly one criterion matches exactly the bindings the index keys, so
897
+ * this is the whole candidate set rather than a prefilter — a multi-criterion slot cannot satisfy
898
+ * it.
932
899
  */
933
900
  #taggedBindingsFromChain(token, tag) {
934
- // A tag matches at most one binding per registry, so a root container's answer is built whole
935
- // rather than grown — the shape `#namedBindingsFromChain` takes, for the same reason.
901
+ // A criterion matches at most one binding per registry, so a root container's answer is built
902
+ // whole rather than grown — the list is sized at its allocation.
936
903
  const ownBinding = this.#registry.getSimpleTagged(token, tag);
937
904
  if (this.#parent === undefined) {
938
905
  return ownBinding === undefined ? [] : [ownBinding];
@@ -979,14 +946,19 @@ export class DependencyResolver {
979
946
  }
980
947
  return buildConstraintContext(resolutionStack, options);
981
948
  }
982
- /** Selection for a name-less multi-tag request, over the union of the two tag indexes. */
949
+ /** Selection for a multi-criterion request, over the union of the two tag indexes. */
983
950
  #selectMultiTagged(token, options, resolutionStack) {
984
951
  const candidates = [];
985
- this.#gatherTagCandidates(token, options.tag, candidates);
952
+ const gathered = [];
953
+ if (options.name !== undefined) {
954
+ // Read, not minted: an unminted name has no criterion, so no slot can carry it.
955
+ this.#gatherTagCandidates(token, slotNameCriterionOf(options.name), candidates, gathered);
956
+ }
957
+ this.#gatherTagCandidates(token, options.tag, candidates, gathered);
986
958
  const listed = options.tags;
987
959
  if (listed !== undefined) {
988
960
  for (let index = 0; index < listed.length; index += 1) {
989
- this.#gatherTagCandidates(token, listed[index], candidates);
961
+ this.#gatherTagCandidates(token, listed[index], candidates, gathered);
990
962
  }
991
963
  }
992
964
  if (candidates.length === 0) {
@@ -995,31 +967,24 @@ export class DependencyResolver {
995
967
  return selectBinding(candidates, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
996
968
  }
997
969
  /** One request criterion's candidates: its exact single-tag binding, plus its first-tag bucket. */
998
- #gatherTagCandidates(token, criterion, out) {
999
- if (criterion === undefined) {
970
+ #gatherTagCandidates(token, criterion, out, gathered) {
971
+ // Distinct criteria never share a binding — a slot lives in exactly one bucket — so deduping
972
+ // by criterion covers a request repeating one across its spellings, without scanning `out`.
973
+ if (criterion === undefined || gathered.includes(criterion)) {
1000
974
  return;
1001
975
  }
1002
- // The includes probes only guard a request repeating a criterion across its two spellings.
976
+ gathered.push(criterion);
1003
977
  const single = this.#registry.getSimpleTagged(token, criterion);
1004
- if (single !== undefined && !out.includes(single)) {
978
+ if (single !== undefined) {
1005
979
  out.push(single);
1006
980
  }
1007
981
  const bucket = this.#registry.getMultiTagged(token, criterion);
1008
982
  if (bucket !== undefined) {
1009
983
  for (let index = 0; index < bucket.length; index += 1) {
1010
- const candidate = bucket[index];
1011
- if (!out.includes(candidate)) {
1012
- out.push(candidate);
1013
- }
984
+ out.push(bucket[index]);
1014
985
  }
1015
986
  }
1016
987
  }
1017
- #matchesBindingFast(binding, options, resolutionStack) {
1018
- if (!matchesSlot(binding.slot, options)) {
1019
- return false;
1020
- }
1021
- return this.#satisfiesPredicate(binding, options, resolutionStack);
1022
- }
1023
988
  /** The predicate half of a match, for a lane whose index has already settled the slot. */
1024
989
  #satisfiesPredicate(binding, options, resolutionStack) {
1025
990
  const predicate = binding.predicate;