@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
@@ -17,15 +17,13 @@ export class BindingRegistry {
17
17
  readonly #bindings = new Map<DependencyKey, Array<Binding>>();
18
18
  // Fast lookup by binding ID
19
19
  readonly #byId = new Map<BindingIdentifier, Binding>();
20
- // Fast lookup for slot { name, tags: [] } — unallocated until a named binding is registered.
21
- #simpleNamed: Map<DependencyKey, Map<string, Binding>> | undefined;
22
20
  // Fast path for one default slot binding with no predicate
23
21
  readonly #fastDefault = new Map<DependencyKey, Binding>();
24
- // Fast lookup for a slot carrying exactly one tag — keyed by the interned criterion itself, so
25
- // the (key, value) pair is one hash rather than two. Unallocated until a tagged binding lands.
22
+ // Fast lookup for a slot carrying exactly one criterion (a lone name folds here too) — keyed by
23
+ // the interned criterion itself, so the pair is one hash. Unallocated until such a slot lands.
26
24
  #simpleTagged: Map<DependencyKey, Map<BindingTag, Binding>> | undefined;
27
- // Name-less slots with two or more tags, bucketed by their FIRST criterion. A matching slot's
28
- // every tag is in the request, so walking the request's buckets finds each candidate exactly once.
25
+ // Slots with two or more criteria, bucketed by their FIRST criterion. A matching slot's every
26
+ // criterion is in the request, so walking the request's buckets finds each candidate exactly once.
29
27
  #multiTagged: Map<DependencyKey, Map<BindingTag, Array<Binding>>> | undefined;
30
28
 
31
29
  // Set on the first constant registered and never cleared. Teardown only needs the negative answer
@@ -79,7 +77,6 @@ export class BindingRegistry {
79
77
  if (existingIndex !== -1) {
80
78
  displacedBinding = bindingsForToken[existingIndex]!;
81
79
  this.#byId.delete(displacedBinding.id);
82
- this.#deindexSimpleNamedBinding(key, displacedBinding);
83
80
  this.#deindexSimpleTaggedBinding(key, displacedBinding);
84
81
  this.#deindexMultiTaggedBinding(key, displacedBinding);
85
82
  }
@@ -92,7 +89,6 @@ export class BindingRegistry {
92
89
 
93
90
  this.#bindings.set(key, nextBindings);
94
91
  this.#byId.set(binding.id, binding);
95
- this.#indexSimpleNamedBinding(key, binding);
96
92
  this.#indexSimpleTaggedBinding(key, binding);
97
93
  this.#indexMultiTaggedBinding(key, binding);
98
94
  this.#refreshFastDefaultForToken(key);
@@ -105,7 +101,6 @@ export class BindingRegistry {
105
101
  const key: DependencyKey = token;
106
102
  const bindingsForToken = this.#bindings.get(key) ?? [];
107
103
  this.#bindings.delete(key);
108
- this.#simpleNamed?.delete(key);
109
104
  this.#simpleTagged?.delete(key);
110
105
  this.#multiTagged?.delete(key);
111
106
  this.#fastDefault.delete(key);
@@ -129,12 +124,10 @@ export class BindingRegistry {
129
124
  const bindingIndex = bindingsForToken.findIndex((candidate) => candidate.id === id);
130
125
  // Copy-on-write, like `add`: a walk holding the current array must not lose its place.
131
126
  const remaining = bindingIndex === -1 ? bindingsForToken : bindingsForToken.toSpliced(bindingIndex, 1);
132
- this.#deindexSimpleNamedBinding(key, binding);
133
127
  this.#deindexSimpleTaggedBinding(key, binding);
134
128
  this.#deindexMultiTaggedBinding(key, binding);
135
129
  if (remaining.length === 0) {
136
130
  this.#bindings.delete(key);
137
- this.#simpleNamed?.delete(key);
138
131
  this.#simpleTagged?.delete(key);
139
132
  this.#multiTagged?.delete(key);
140
133
  this.#fastDefault.delete(key);
@@ -178,7 +171,6 @@ export class BindingRegistry {
178
171
  const all = this.allBindings();
179
172
  this.#bindings.clear();
180
173
  this.#byId.clear();
181
- this.#simpleNamed?.clear();
182
174
  this.#simpleTagged?.clear();
183
175
  this.#multiTagged?.clear();
184
176
  this.#fastDefault.clear();
@@ -199,10 +191,6 @@ export class BindingRegistry {
199
191
  );
200
192
  }
201
193
 
202
- getSimpleNamed(token: Token<unknown> | Constructor, name: string): Binding | undefined {
203
- return this.#simpleNamed?.get(token)?.get(name);
204
- }
205
-
206
194
  /**
207
195
  * The binding indexed under one criterion.
208
196
  *
@@ -287,33 +275,6 @@ export class BindingRegistry {
287
275
  }
288
276
  }
289
277
 
290
- #indexSimpleNamedBinding(tokenKey: DependencyKey, binding: Binding): void {
291
- const name = simpleNameOf(binding);
292
- if (name === undefined) {
293
- return;
294
- }
295
- this.#simpleNamed ??= new Map();
296
- const bindingsByName = getOrInsert(this.#simpleNamed, tokenKey, new Map<string, Binding>());
297
- bindingsByName.set(name, binding);
298
- }
299
-
300
- #deindexSimpleNamedBinding(tokenKey: DependencyKey, binding: Binding): void {
301
- const name = simpleNameOf(binding);
302
- if (name === undefined) {
303
- return;
304
- }
305
- const bindingsByName = this.#simpleNamed?.get(tokenKey);
306
- if (bindingsByName === undefined) {
307
- return;
308
- }
309
- if (bindingsByName.get(name)?.id === binding.id) {
310
- bindingsByName.delete(name);
311
- if (bindingsByName.size === 0) {
312
- this.#simpleNamed!.delete(tokenKey);
313
- }
314
- }
315
- }
316
-
317
278
  #refreshFastDefaultForToken(tokenKey: DependencyKey): void {
318
279
  const bindingsForToken = this.#bindings.get(tokenKey);
319
280
  const onlyBinding = bindingsForToken?.length === 1 ? bindingsForToken[0]! : undefined;
@@ -324,48 +285,35 @@ export class BindingRegistry {
324
285
  this.#fastDefault.delete(tokenKey);
325
286
  }
326
287
 
327
- /** Whether the deferred named-slot index has had to be built. */
328
- get isNamedIndexBuilt(): boolean {
329
- return this.#simpleNamed !== undefined;
330
- }
331
-
332
288
  /** Whether the deferred tagged-slot index has had to be built. */
333
289
  get isTaggedIndexBuilt(): boolean {
334
290
  return this.#simpleTagged !== undefined;
335
291
  }
336
292
  }
337
293
 
338
- /** The name a binding is indexed under, or `undefined` when its slot is more than a plain name. */
339
- function simpleNameOf(binding: Binding): string | undefined {
340
- const { name, tags } = binding.slot;
341
- return name !== undefined && tags.length === 0 ? name : undefined;
342
- }
343
-
344
294
  /**
345
- * The tag a binding is indexed under, or `undefined` when its slot is more than one plain tag.
295
+ * The criterion a binding is indexed under, or `undefined` when its slot carries more than one.
346
296
  *
347
- * @remarks Carries predicate-bearing bindings too, exactly as the name index does: every lane that
348
- * reads this index already re-checks what it finds, so an indexed hit was never unconditional.
297
+ * @remarks Carries predicate-bearing bindings too: every lane that reads this index already
298
+ * re-checks what it finds, so an indexed hit was never unconditional.
349
299
  */
350
300
  function simpleTagOf(binding: Binding): BindingTag | undefined {
351
- const { name, tags } = binding.slot;
352
- return name === undefined && tags.length === 1 ? tags[0] : undefined;
301
+ const { tags } = binding.slot;
302
+ return tags.length === 1 ? tags[0] : undefined;
353
303
  }
354
304
 
355
- /** The first criterion a multi-tag slot is bucketed under, or `undefined` for any other shape. */
305
+ /** The first criterion a multi-criterion slot is bucketed under, or `undefined` for any other shape. */
356
306
  function multiTagFirstOf(binding: Binding): BindingTag | undefined {
357
- const { name, tags } = binding.slot;
358
- return name === undefined && tags.length >= 2 ? tags[0] : undefined;
307
+ const { tags } = binding.slot;
308
+ return tags.length >= 2 ? tags[0] : undefined;
359
309
  }
360
310
 
361
311
  /** A binding nothing has to be matched against: the default slot, no predicate. */
362
312
  function isDefaultSlotBinding(binding: Binding): boolean {
363
- const { name, tags } = binding.slot;
364
- return name === undefined && tags.length === 0 && binding.predicate === undefined;
313
+ return binding.slot.tags.length === 0 && binding.predicate === undefined;
365
314
  }
366
315
 
367
316
  /** A predicate with no slot constraint: last-wins does not apply to it. */
368
317
  function isPurePredicateBinding(binding: Binding): boolean {
369
- const { name, tags } = binding.slot;
370
- return binding.predicate !== undefined && name === undefined && tags.length === 0;
318
+ return binding.predicate !== undefined && binding.slot.tags.length === 0;
371
319
  }
package/src/core/tag.ts CHANGED
@@ -58,6 +58,14 @@ export interface TagKey<Value = unknown> {
58
58
  readonly mask: TagKeyMask;
59
59
  /** The criterion for one value, interned: the same value always yields the same object. */
60
60
  of(value: Value): BindingTag<Value>;
61
+ /**
62
+ * The interned criterion for a value, or `undefined` when none was ever minted.
63
+ *
64
+ * @remarks Reading without minting is what keeps a request-side value from being retained for
65
+ * the process lifetime: a value no binding ever declared has no criterion, so a lookup can
66
+ * answer "no match" without inserting one.
67
+ */
68
+ peek(value: Value): BindingTag<Value> | undefined;
61
69
  }
62
70
 
63
71
  let tagKeyCounter = -1;
@@ -101,6 +109,28 @@ export function tag<Value = unknown>(name: string): TagKey<Value> {
101
109
  let lastValue: Value | undefined;
102
110
  let lastPair: BindingTag<Value> | undefined;
103
111
 
112
+ // The miss paths live outside `of()`/`peek()` so the hot wrappers stay small enough to inline.
113
+ const internPair = (value: Value): BindingTag<Value> => {
114
+ const cacheKey = internKeyFor(value);
115
+ const existing = interned.get(cacheKey);
116
+
117
+ if (existing !== undefined) {
118
+ lastValue = value;
119
+ lastPair = existing;
120
+
121
+ return existing;
122
+ }
123
+
124
+ const pair = { key, value, mask } as BindingTag<Value>;
125
+
126
+ interned.set(cacheKey, pair);
127
+ lastValue = value;
128
+ lastPair = pair;
129
+
130
+ return pair;
131
+ };
132
+ const peekInterned = (value: Value): BindingTag<Value> | undefined => interned.get(internKeyFor(value));
133
+
104
134
  const key: TagKey<Value> = {
105
135
  name,
106
136
  id,
@@ -109,30 +139,54 @@ export function tag<Value = unknown>(name: string): TagKey<Value> {
109
139
  if (lastPair !== undefined && Object.is(value, lastValue)) {
110
140
  return lastPair;
111
141
  }
112
-
113
- const cacheKey = internKeyFor(value);
114
- const existing = interned.get(cacheKey);
115
-
116
- if (existing !== undefined) {
117
- lastValue = value;
118
- lastPair = existing;
119
-
120
- return existing;
142
+ return internPair(value);
143
+ },
144
+ peek(value: Value): BindingTag<Value> | undefined {
145
+ if (lastPair !== undefined && Object.is(value, lastValue)) {
146
+ return lastPair;
121
147
  }
122
-
123
- const pair = { key, value, mask } as BindingTag<Value>;
124
-
125
- interned.set(cacheKey, pair);
126
- lastValue = value;
127
- lastPair = pair;
128
-
129
- return pair;
148
+ return peekInterned(value);
130
149
  },
131
150
  };
132
151
 
133
152
  return key;
134
153
  }
135
154
 
155
+ /**
156
+ * The reserved key a slot's name is a criterion of.
157
+ *
158
+ * @remarks `whenNamed(n)` and a request's `name` are sugar for `slotName.of(n)`, so one selection
159
+ * model serves both spellings — a name takes part in key masks, indexes and specificity like any
160
+ * criterion. What reserves the key is its identity; diagnostics render its criteria as `name:<value>`.
161
+ *
162
+ * @since 0.8.0
163
+ */
164
+ export const slotName: TagKey<string> = tag<string>("di:name");
165
+
166
+ // One-entry front for the reserved key's read: a name's criterion never changes once minted, so a
167
+ // hit is sound forever, and a miss is never cached so a later `whenNamed` bind is still seen.
168
+ let lastPeekedName: string | undefined;
169
+ let lastPeekedCriterion: BindingTag<string> | undefined;
170
+
171
+ /**
172
+ * The reserved criterion for a name, or `undefined` while no binding has declared it.
173
+ *
174
+ * @remarks Reads without minting, so a request-side name no binding declared is never retained.
175
+ *
176
+ * @since 0.8.0
177
+ */
178
+ export function slotNameCriterionOf(name: string): BindingTag<string> | undefined {
179
+ if (name === lastPeekedName) {
180
+ return lastPeekedCriterion;
181
+ }
182
+ const criterion = slotName.peek(name);
183
+ if (criterion !== undefined) {
184
+ lastPeekedName = name;
185
+ lastPeekedCriterion = criterion;
186
+ }
187
+ return criterion;
188
+ }
189
+
136
190
  /**
137
191
  * The key set a list of criteria covers.
138
192
  *
@@ -1,3 +1,5 @@
1
+ import type { BindingTag } from "#/core/tag";
2
+ import { slotName } from "#/core/tag";
1
3
  import type { BindingIdentifier, BindingScope, ResolveOptions } from "#/core/types";
2
4
 
3
5
  /**
@@ -43,13 +45,34 @@ export class TokenNotBoundError extends DiError {
43
45
  }
44
46
 
45
47
  // Options carry caller values a tag may hold — a bigint or a circular object must not make the
46
- // diagnostic itself throw and mask the real error.
48
+ // diagnostic itself throw and mask the real error. The reserved criterion renders as `name`.
47
49
  function describeResolveOptions(options: ResolveOptions): string {
48
50
  try {
49
- return (
50
- JSON.stringify(options, (_key, value: unknown) => (typeof value === "bigint" ? `${String(value)}n` : value)) ??
51
- "undefined"
52
- );
51
+ let name = options.name;
52
+ const criteria: Array<string> = [];
53
+ const add = (criterion: BindingTag): void => {
54
+ if (criterion.key === slotName) {
55
+ name ??= String(criterion.value);
56
+ } else {
57
+ criteria.push(`${criterion.key.name}=${String(criterion.value)}`);
58
+ }
59
+ };
60
+ if (options.tag !== undefined) {
61
+ add(options.tag);
62
+ }
63
+ if (options.tags !== undefined) {
64
+ for (const criterion of options.tags) {
65
+ add(criterion);
66
+ }
67
+ }
68
+ const display: { name?: string; tags?: Array<string> } = {};
69
+ if (name !== undefined) {
70
+ display.name = name;
71
+ }
72
+ if (criteria.length > 0) {
73
+ display.tags = criteria;
74
+ }
75
+ return JSON.stringify(display) ?? "undefined";
53
76
  } catch {
54
77
  return "[unserializable options]";
55
78
  }
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export { token, tokenName } from "#/core/token";
21
21
  export type { Token } from "#/core/token";
22
22
 
23
23
  // Tag — the interned slot criteria a `whenTagged` and a resolve both take
24
- export { coversTagKeys, NO_TAG_KEYS, tag, tagKeyMaskOf } from "#/core/tag";
24
+ export { coversTagKeys, NO_TAG_KEYS, slotName, tag, tagKeyMaskOf } from "#/core/tag";
25
25
  export type { TagKey, TagKeyMask } from "#/core/tag";
26
26
 
27
27
  // Binding builders — types only
@@ -1,4 +1,5 @@
1
1
  import type { BindingTag } from "#/core/tag";
2
+ import { slotName, slotNameCriterionOf } from "#/core/tag";
2
3
  import type { Token } from "#/core/token";
3
4
  import type { Constructor, ResolveOptions } from "#/core/types";
4
5
 
@@ -19,37 +20,39 @@ export interface DependencySlot {
19
20
  }
20
21
 
21
22
  /**
22
- * A request whose only criterion is a name — the shape the registry has a direct index for.
23
+ * The lone criterion of a request that carries exactly one, whatever its spelling — the shape the
24
+ * registry has a direct index for.
23
25
  *
24
- * @since 0.5.0-canary.9
25
- */
26
- export function isNameOnlyOptions(options: ResolveOptions): options is ResolveOptions & { name: string } {
27
- return (
28
- options.name !== undefined && options.tag === undefined && (options.tags === undefined || options.tags.length === 0)
29
- );
30
- }
31
-
32
- /**
33
- * The lone tag of a request that asks for exactly one, written either way — the shape the registry
34
- * has a direct tag index for.
35
- *
36
- * @remarks Both spellings answer here, so the index is not something one of them silently misses.
26
+ * @remarks Every spelling answers here, so the index is not something one of them silently misses;
27
+ * a name folds to the reserved `slotName` criterion, making the name lane the tag lane.
37
28
  *
38
29
  * @since 0.5.0-canary.9
39
30
  */
40
- export function singleTagOnlyOf(options: ResolveOptions): BindingTag | undefined {
41
- if (options.name !== undefined) {
31
+ export function singleCriterionOnlyOf(options: ResolveOptions | undefined): BindingTag | undefined {
32
+ if (options === undefined) {
42
33
  return undefined;
43
34
  }
35
+ if (options.name !== undefined) {
36
+ return loneNameCriterionOf(options);
37
+ }
44
38
  const listed = options.tags;
45
39
  const shorthand = options.tag;
46
40
  if (shorthand !== undefined) {
47
- // Both sources present means the request carries two tags, which no single-tag index can answer.
48
41
  return listed === undefined || listed.length === 0 ? shorthand : undefined;
49
42
  }
50
43
  return listed !== undefined && listed.length === 1 ? listed[0] : undefined;
51
44
  }
52
45
 
46
+ /** The name spelling's half of the fold, kept apart so the common body stays small enough to inline. */
47
+ function loneNameCriterionOf(options: ResolveOptions): BindingTag | undefined {
48
+ // A name next to any tag means the request carries two criteria, which no single index answers.
49
+ if (options.tag !== undefined || (options.tags !== undefined && options.tags.length > 0)) {
50
+ return undefined;
51
+ }
52
+ // Read, not minted: a request must not retain a name no binding ever declared.
53
+ return slotNameCriterionOf(options.name as string);
54
+ }
55
+
53
56
  /** Shared core: build a ResolveOptions from already-normalised name + tags. */
54
57
  function buildOptions(
55
58
  name: string | undefined,
@@ -83,8 +86,12 @@ export function injectionSlotToResolveOptions(
83
86
  /** Where a slot's derived options are memoized, so the same object is handed out every resolve. */
84
87
  const MEMOIZED_RESOLVE_OPTIONS: unique symbol = Symbol("di:resolve-options");
85
88
 
89
+ /** Where a slot's folded lone criterion is memoized — `null` records "computed: none". */
90
+ const MEMOIZED_SINGLE_CRITERION: unique symbol = Symbol("di:single-criterion");
91
+
86
92
  interface SlotWithMemoizedOptions {
87
93
  [MEMOIZED_RESOLVE_OPTIONS]?: ResolveOptions;
94
+ [MEMOIZED_SINGLE_CRITERION]?: BindingTag | null;
88
95
  }
89
96
 
90
97
  /**
@@ -126,6 +133,44 @@ function memoizeResolveOptions(
126
133
  return built;
127
134
  }
128
135
 
136
+ /**
137
+ * The lone criterion a dependency's fixed criteria fold to, memoized on the slot like its options —
138
+ * `null` means the fold answered "none".
139
+ *
140
+ * @remarks A slot's criteria never change after declaration, so the fold happens once per slot
141
+ * rather than per hop — which is what keeps a named dependency's resolve off the intern map. A
142
+ * lone name whose criterion is not interned yet is left unmemoized: a later `whenNamed` binding
143
+ * mints it, and the next fold must see that.
144
+ *
145
+ * @since 0.8.0
146
+ */
147
+ export function singleCriterionForSlot(injectionSlot: DependencySlot): BindingTag | null {
148
+ const slot = injectionSlot as SlotWithMemoizedOptions;
149
+ const memoized = slot[MEMOIZED_SINGLE_CRITERION];
150
+ if (memoized !== undefined) {
151
+ return memoized;
152
+ }
153
+ const options = resolveOptionsForSlot(injectionSlot);
154
+ const criterion = singleCriterionOnlyOf(options);
155
+ const folded = criterion ?? null;
156
+ if (criterion !== undefined || !isLoneNameOptions(options)) {
157
+ try {
158
+ slot[MEMOIZED_SINGLE_CRITERION] = folded;
159
+ } catch {
160
+ // A frozen slot re-folds on every hop rather than throwing.
161
+ }
162
+ }
163
+ return folded;
164
+ }
165
+
166
+ /** A slot request whose only criterion is a name — the one shape whose fold can change after a bind. */
167
+ function isLoneNameOptions(options: ResolveOptions | undefined): boolean {
168
+ // Slot-derived options never carry the `tag` shorthand — `buildOptions` folds it into `tags`.
169
+ return (
170
+ options !== undefined && options.name !== undefined && (options.tags === undefined || options.tags.length === 0)
171
+ );
172
+ }
173
+
129
174
  /**
130
175
  * Resolve options derived from a binding slot (tags may be empty; omits when nothing to match).
131
176
  *
@@ -140,5 +185,17 @@ export function bindingSlotToResolveOptions(bindingSlot: {
140
185
  readonly tags?: ReadonlyArray<BindingTag> | undefined;
141
186
  }): ResolveOptions | undefined {
142
187
  const tags = bindingSlot.tags;
143
- return buildOptions(bindingSlot.name, tags !== undefined && tags.length > 0 ? tags : undefined);
188
+ let name = bindingSlot.name;
189
+ let criteria: ReadonlyArray<BindingTag> | undefined = tags !== undefined && tags.length > 0 ? tags : undefined;
190
+ if (criteria !== undefined) {
191
+ // A reserved criterion folds into `name` — carried in `tags` it would be said twice or, on a
192
+ // descriptor that spells its name as a tag, not at all.
193
+ const reserved = criteria.find((criterion) => criterion.key === slotName);
194
+ if (reserved !== undefined) {
195
+ name ??= reserved.value as string;
196
+ const rest = criteria.filter((criterion) => criterion.key !== slotName);
197
+ criteria = rest.length > 0 ? rest : undefined;
198
+ }
199
+ }
200
+ return buildOptions(name, criteria);
144
201
  }
@@ -1,6 +1,7 @@
1
1
  import type { Binding } from "#/core/binding";
2
2
  import { effectiveBindingScope } from "#/core/binding-scope";
3
3
  import type { BindingRegistry } from "#/core/registry";
4
+ import { slotName } from "#/core/tag";
4
5
  import type { Token } from "#/core/token";
5
6
  import { tokenName } from "#/core/token";
6
7
  import type { BindingKind, BindingScope, Constructor } from "#/core/types";
@@ -96,12 +97,23 @@ function matchingTargets(candidates: ReadonlyArray<Binding>, ref: DependencySlot
96
97
  return candidates.filter((candidate) => matchesSlot(candidate.slot, criterion));
97
98
  }
98
99
 
100
+ /** The name a dependency asks for, whichever spelling carries it. */
101
+ function refSlotName(ref: DependencySlot): string | undefined {
102
+ if (ref.name !== undefined) {
103
+ return ref.name;
104
+ }
105
+ const reserved = ref.tags?.find((criterion) => criterion.key === slotName);
106
+ return reserved === undefined ? undefined : String(reserved.value);
107
+ }
108
+
99
109
  function edgeLabel(ref: DependencySlot, index: number): string {
110
+ const name = refSlotName(ref);
111
+ const firstPlainTag = ref.tags?.find((criterion) => criterion.key !== slotName);
100
112
  const criterion =
101
- ref.name !== undefined
102
- ? `name:${ref.name}`
103
- : ref.tags !== undefined && ref.tags.length > 0
104
- ? `tag:${ref.tags[0]?.key.name}=${String(ref.tags[0]?.value)}`
113
+ name !== undefined
114
+ ? `name:${name}`
115
+ : firstPlainTag !== undefined
116
+ ? `tag:${firstPlainTag.key.name}=${String(firstPlainTag.value)}`
105
117
  : `[${index}]`;
106
118
 
107
119
  return ref.optional ? `${criterion} optional` : criterion;
@@ -170,23 +182,25 @@ function addDependencyEdges(
170
182
  return;
171
183
  }
172
184
 
185
+ const unboundSlotName = refSlotName(ref);
173
186
  accumulator.edges.push({
174
187
  from,
175
188
  to: unboundNodeIdFor(accumulator, ref.token),
176
189
  label,
177
190
  optional: true,
178
- ...(ref.name !== undefined ? { slotName: ref.name } : {}),
191
+ ...(unboundSlotName !== undefined ? { slotName: unboundSlotName } : {}),
179
192
  });
180
193
 
181
194
  return;
182
195
  }
183
196
 
197
+ const requestedName = refSlotName(ref);
184
198
  for (const target of targets) {
185
199
  // A multi dep with no criterion of its own fans out — each edge names the slot it hits.
186
- const slotName = target.slot.name ?? ref.name;
200
+ const edgeSlotName = target.slot.name ?? requestedName;
187
201
  const perTargetLabel =
188
- ref.multi && ref.name === undefined && slotName !== undefined
189
- ? edgeLabel({ ...ref, name: slotName }, index)
202
+ ref.multi && requestedName === undefined && edgeSlotName !== undefined
203
+ ? edgeLabel({ ...ref, name: edgeSlotName }, index)
190
204
  : label;
191
205
 
192
206
  accumulator.edges.push({
@@ -194,7 +208,7 @@ function addDependencyEdges(
194
208
  to: target.id,
195
209
  label: perTargetLabel,
196
210
  optional: ref.optional,
197
- ...(slotName !== undefined ? { slotName } : {}),
211
+ ...(edgeSlotName !== undefined ? { slotName: edgeSlotName } : {}),
198
212
  });
199
213
  }
200
214
  }
@@ -31,15 +31,10 @@ export interface DefaultLookupEntry<Owner> {
31
31
  */
32
32
  export const ALIAS_HOP_LIMIT = 32;
33
33
 
34
- /**
35
- * @since 0.5.0-canary.8
36
- */
37
- const newNameToEntryMap = <Owner>(): Map<string, DefaultLookupEntry<Owner> | null> => new Map();
38
-
39
34
  const newTagToEntryMap = <Owner>(): Map<BindingTag, DefaultLookupEntry<Owner> | null> => new Map();
40
35
 
41
36
  /**
42
- * A version-stamped cache of binding lookups by token, name, and tag across the container chain.
37
+ * A version-stamped cache of binding lookups by token and criterion across the container chain.
43
38
  *
44
39
  * @since 0.5.0-canary.9
45
40
  */
@@ -51,8 +46,6 @@ export class BindingLookupCache<Owner> {
51
46
  // is tracked by the token slot rather than by the entry.
52
47
  #lastToken: Token<unknown> | Constructor | undefined;
53
48
  #lastEntry: DefaultLookupEntry<Owner> | null = null;
54
- readonly #byTokenAndName = new Map<Token<unknown> | Constructor, Map<string, DefaultLookupEntry<Owner> | null>>();
55
- #namedVersion = -1;
56
49
  readonly #byTokenAndTag = new Map<Token<unknown> | Constructor, Map<BindingTag, DefaultLookupEntry<Owner> | null>>();
57
50
  #taggedVersion = -1;
58
51
  // One entry in front of the tag map, and the map is not written until a second distinct request
@@ -101,24 +94,6 @@ export class BindingLookupCache<Owner> {
101
94
  return entry;
102
95
  }
103
96
 
104
- /** `null` when the name's shape needs the full selection path. */
105
- namedEntry(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry<Owner> | null {
106
- const version = this.chainVersion();
107
- if (version !== this.#namedVersion) {
108
- this.#byTokenAndName.clear();
109
- this.#namedVersion = version;
110
- }
111
- // Computed, not eager: this runs on every named resolve, and the eager form would allocate a
112
- // Map per call only to discard it on the hit that follows.
113
- const byName = getOrInsertComputed(this.#byTokenAndName, token, newNameToEntryMap);
114
- let entry = byName.get(name);
115
- if (entry === undefined) {
116
- entry = this.#findNamedInChain(token, name);
117
- byName.set(name, entry);
118
- }
119
- return entry;
120
- }
121
-
122
97
  /** `null` when the tag's shape needs the full selection path. */
123
98
  taggedEntry(token: Token<unknown> | Constructor, tag: BindingTag): DefaultLookupEntry<Owner> | null {
124
99
  const version = this.chainVersion();
@@ -177,21 +152,6 @@ export class BindingLookupCache<Owner> {
177
152
  return this.#parent === undefined ? null : this.#parent.#findDefaultInChain(token);
178
153
  }
179
154
 
180
- #findNamedInChain(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry<Owner> | null {
181
- const named = this.#registry.getSimpleNamed(token, name);
182
- if (named !== undefined) {
183
- // Predicates need a live context; aliases carry options through the full path.
184
- if (named.predicate !== undefined || named.kind === "alias") {
185
- return null;
186
- }
187
- return { binding: named, owner: this.#owner };
188
- }
189
- if (this.#registry.has(token)) {
190
- return null;
191
- }
192
- return this.#parent === undefined ? null : this.#parent.#findNamedInChain(token, name);
193
- }
194
-
195
155
  #findTaggedInChain(token: Token<unknown> | Constructor, tag: BindingTag): DefaultLookupEntry<Owner> | null {
196
156
  const tagged = this.#registry.getSimpleTagged(token, tag);
197
157
  if (tagged !== undefined) {