@codefast/di 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +71 -7
  3. package/dist/ambient/active-container.d.ts +13 -11
  4. package/dist/ambient/active-container.js +8 -6
  5. package/dist/container/binding-builders.d.ts +25 -7
  6. package/dist/container/binding-builders.js +110 -42
  7. package/dist/container/container.d.ts +8 -8
  8. package/dist/container/container.js +244 -67
  9. package/dist/core/binding-declaration.d.ts +120 -0
  10. package/dist/core/binding-declaration.js +186 -0
  11. package/dist/core/binding-scope.d.ts +2 -2
  12. package/dist/core/binding.d.ts +61 -9
  13. package/dist/core/binding.js +49 -2
  14. package/dist/core/constraint-requirement.d.ts +1 -1
  15. package/dist/core/module.d.ts +10 -7
  16. package/dist/core/module.js +17 -3
  17. package/dist/core/registry.d.ts +17 -13
  18. package/dist/core/registry.js +155 -59
  19. package/dist/core/state-epoch.d.ts +18 -1
  20. package/dist/core/state-epoch.js +17 -0
  21. package/dist/core/tag.js +1 -1
  22. package/dist/core/token.d.ts +1 -1
  23. package/dist/core/types.d.ts +5 -5
  24. package/dist/decorators/decorator-metadata.d.ts +9 -0
  25. package/dist/decorators/decorator-metadata.js +20 -0
  26. package/dist/decorators/inject.d.ts +3 -3
  27. package/dist/decorators/inject.js +7 -6
  28. package/dist/decorators/injectable.d.ts +2 -2
  29. package/dist/decorators/injectable.js +5 -3
  30. package/dist/decorators/lifecycle-decorators.js +9 -3
  31. package/dist/errors/errors.d.ts +85 -14
  32. package/dist/errors/errors.js +100 -20
  33. package/dist/index.d.ts +37 -35
  34. package/dist/index.js +20 -19
  35. package/dist/injection/descriptor.d.ts +3 -3
  36. package/dist/injection/descriptor.js +3 -7
  37. package/dist/injection/resolve-options.d.ts +3 -3
  38. package/dist/injection/resolve-options.js +7 -5
  39. package/dist/introspection/dependency-graph.d.ts +10 -5
  40. package/dist/introspection/dependency-graph.js +51 -28
  41. package/dist/introspection/graph-adapters/cytoscape.d.ts +1 -1
  42. package/dist/introspection/graph-adapters/dot.d.ts +1 -1
  43. package/dist/introspection/graph-adapters/mermaid.d.ts +1 -1
  44. package/dist/introspection/graph-adapters/reactflow.d.ts +1 -1
  45. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  46. package/dist/introspection/inspector.d.ts +4 -4
  47. package/dist/introspection/inspector.js +9 -12
  48. package/dist/lifecycle/lifecycle-manager.d.ts +4 -4
  49. package/dist/lifecycle/lifecycle-manager.js +18 -6
  50. package/dist/lifecycle/scope-manager.d.ts +2 -2
  51. package/dist/lifecycle/scope-manager.js +31 -13
  52. package/dist/metadata/metadata-reader-token.d.ts +2 -2
  53. package/dist/metadata/metadata-reader-token.js +1 -1
  54. package/dist/metadata/metadata-types.d.ts +3 -3
  55. package/dist/metadata/symbol-metadata-reader.d.ts +3 -3
  56. package/dist/metadata/symbol-metadata-reader.js +1 -1
  57. package/dist/metadata/verifying-metadata-reader.d.ts +5 -4
  58. package/dist/metadata/verifying-metadata-reader.js +30 -8
  59. package/dist/resolution/async-fan-out.d.ts +12 -0
  60. package/dist/resolution/async-fan-out.js +26 -0
  61. package/dist/resolution/cache/activation-need.d.ts +4 -5
  62. package/dist/resolution/cache/activation-need.js +11 -18
  63. package/dist/resolution/cache/binding-lookup-cache.d.ts +5 -12
  64. package/dist/resolution/cache/binding-lookup-cache.js +32 -19
  65. package/dist/resolution/cache/class-introspector.d.ts +16 -5
  66. package/dist/resolution/cache/class-introspector.js +73 -56
  67. package/dist/resolution/context.d.ts +17 -25
  68. package/dist/resolution/context.js +47 -56
  69. package/dist/resolution/path/resolution-path.d.ts +48 -13
  70. package/dist/resolution/path/resolution-path.js +90 -39
  71. package/dist/resolution/plan/instantiation-plan.d.ts +5 -5
  72. package/dist/resolution/plan/instantiation-plan.js +66 -26
  73. package/dist/resolution/plan/plan-codegen.d.ts +10 -7
  74. package/dist/resolution/plan/plan-codegen.js +62 -34
  75. package/dist/resolution/resolver.d.ts +16 -17
  76. package/dist/resolution/resolver.js +316 -273
  77. package/dist/resolution/select/binding-select.d.ts +6 -5
  78. package/dist/resolution/select/binding-select.js +5 -4
  79. package/dist/resolution/select/constraints.d.ts +3 -3
  80. package/dist/resolution/select/constraints.js +4 -4
  81. package/package.json +14 -2
@@ -1,6 +1,6 @@
1
- import { NO_INSTANCE } from "#/core/binding";
2
- import { tokenName } from "#/core/token";
3
- import { MissingScopeContextError } from "#/errors/errors";
1
+ import { NO_INSTANCE } from "#core/binding";
2
+ import { tokenName } from "#core/token";
3
+ import { MissingScopeContextError } from "#errors/errors";
4
4
  /**
5
5
  * One container's instance caches — singletons, in-flight async creations, and the scoped cache.
6
6
  *
@@ -39,22 +39,39 @@ export class ScopeManager {
39
39
  }
40
40
  binding.instance = instance;
41
41
  }
42
+ // A removal only clears the instance; the list keeps its entry until a read compacts it, so a
43
+ // teardown of a hundred singletons is a hundred field writes, not a hundred splices.
44
+ #compacted = true;
42
45
  /** Every binding in this container holding a cached singleton. */
43
46
  cachedSingletons() {
44
- return this.#singletonBindings ?? EMPTY_BINDINGS;
47
+ const tracked = this.#singletonBindings;
48
+ if (tracked === undefined) {
49
+ return EMPTY_BINDINGS;
50
+ }
51
+ if (!this.#compacted) {
52
+ // Latest materialization wins the position: walk from the end keeping each live binding once.
53
+ const seen = new Set();
54
+ const live = [];
55
+ for (let index = tracked.length - 1; index >= 0; index -= 1) {
56
+ const binding = tracked[index];
57
+ if (binding.instance !== NO_INSTANCE && !seen.has(binding)) {
58
+ seen.add(binding);
59
+ live.push(binding);
60
+ }
61
+ }
62
+ live.reverse();
63
+ this.#singletonBindings = live;
64
+ this.#compacted = true;
65
+ return live;
66
+ }
67
+ return tracked;
45
68
  }
46
69
  deleteSingleton(binding) {
47
70
  if (binding.instance === NO_INSTANCE) {
48
71
  return false;
49
72
  }
50
73
  binding.instance = NO_INSTANCE;
51
- const tracked = this.#singletonBindings;
52
- if (tracked !== undefined) {
53
- const index = tracked.indexOf(binding);
54
- if (index !== -1) {
55
- tracked.splice(index, 1);
56
- }
57
- }
74
+ this.#compacted = false;
58
75
  return true;
59
76
  }
60
77
  /** Swaps a re-slotted binding's tracked entry, so teardown pairs the instance with the live object. */
@@ -112,10 +129,11 @@ export class ScopeManager {
112
129
  clearAll() {
113
130
  const tracked = this.#singletonBindings;
114
131
  if (tracked !== undefined) {
115
- for (const binding of tracked) {
116
- binding.instance = NO_INSTANCE;
132
+ for (let index = 0; index < tracked.length; index += 1) {
133
+ tracked[index].instance = NO_INSTANCE;
117
134
  }
118
135
  tracked.length = 0;
136
+ this.#compacted = true;
119
137
  }
120
138
  this.#inflight?.clear();
121
139
  this.#scoped?.clear();
@@ -1,5 +1,5 @@
1
- import type { Token } from "#/core/token";
2
- import type { MetadataReader } from "#/metadata/metadata-types";
1
+ import type { Token } from "#core/token";
2
+ import type { MetadataReader } from "#metadata/metadata-types";
3
3
  /**
4
4
  * The token a custom `MetadataReader` is bound to for a container to pick up.
5
5
  *
@@ -1,4 +1,4 @@
1
- import { token } from "#/core/token";
1
+ import { token } from "#core/token";
2
2
  /**
3
3
  * The token a custom `MetadataReader` is bound to for a container to pick up.
4
4
  *
@@ -1,6 +1,6 @@
1
- import type { Constructor } from "#/core/types";
2
- import type { InjectionDescriptor } from "#/injection/descriptor";
3
- import type { DependencySlot } from "#/injection/resolve-options";
1
+ import type { Constructor } from "#core/types";
2
+ import type { InjectionDescriptor } from "#injection/descriptor";
3
+ import type { DependencySlot } from "#injection/resolve-options";
4
4
  /**
5
5
  * One constructor parameter's declaration.
6
6
  *
@@ -1,6 +1,6 @@
1
- import type { Constructor } from "#/core/types";
2
- import type { InjectionDescriptor } from "#/injection/descriptor";
3
- import type { ConstructorMetadata, LifecycleMetadata, MetadataReader } from "#/metadata/metadata-types";
1
+ import type { Constructor } from "#core/types";
2
+ import type { InjectionDescriptor } from "#injection/descriptor";
3
+ import type { ConstructorMetadata, LifecycleMetadata, MetadataReader } from "#metadata/metadata-types";
4
4
  type AccessorEntry = {
5
5
  readonly key: string | symbol;
6
6
  readonly descriptor: InjectionDescriptor;
@@ -1,4 +1,4 @@
1
- import { INJECT_ACCESSOR_KEY, INJECTABLE_KEY, LIFECYCLE_KEY, METADATA_SYMBOL } from "#/metadata/metadata-keys";
1
+ import { INJECT_ACCESSOR_KEY, INJECTABLE_KEY, LIFECYCLE_KEY, METADATA_SYMBOL } from "#metadata/metadata-keys";
2
2
  /**
3
3
  * The default `MetadataReader`, reading decorator metadata stored under `Symbol.metadata`.
4
4
  *
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Wraps a foreign {@link MetadataReader} so its answers are verified before anything dereferences
3
- * them.
3
+ * them, and asked for once per class.
4
4
  */
5
- import type { MetadataReader } from "#/metadata/metadata-types";
5
+ import type { MetadataReader } from "#metadata/metadata-types";
6
6
  /**
7
- * The reader a container should hand its resolver: verified when it came from outside.
7
+ * The reader a container should hand its resolver: verified and memoized when it came from outside.
8
8
  *
9
9
  * @remarks The decorator reader writes the metadata it later reads, so there is nothing to check and
10
10
  * nothing to pay — a container that supplies no reader of its own is left on the same code path it
11
- * has always taken. A supplied reader is a claim, and only its callers can be charged for checking.
11
+ * has always taken. A supplied reader is a claim, and only its callers can be charged for checking;
12
+ * it is asked about a class once, however many containers read through it.
12
13
  *
13
14
  * @since 0.6.0
14
15
  */
@@ -1,17 +1,34 @@
1
1
  /**
2
2
  * Wraps a foreign {@link MetadataReader} so its answers are verified before anything dereferences
3
- * them.
3
+ * them, and asked for once per class.
4
4
  */
5
- import { defaultMetadataReader } from "#/metadata/symbol-metadata-reader";
6
- import { verifyAccessorMetadata, verifyConstructorMetadata, verifyLifecycleMetadata, } from "#/resolution/cache/class-introspector";
5
+ import { defaultMetadataReader } from "#metadata/symbol-metadata-reader";
6
+ import { verifyAccessorMetadata, verifyConstructorMetadata, verifyLifecycleMetadata, } from "#resolution/cache/class-introspector";
7
7
  // Wrapping a wrapper would stack a layer per child container, so each one is remembered.
8
8
  const verifyingReaders = new WeakSet();
9
+ // One wrapper per foreign reader, so every container handed that reader shares its answers.
10
+ const wrapperByReader = new WeakMap();
11
+ // `null` records a verified "no metadata", so an absent answer is not asked for again either.
12
+ function memoized(ask) {
13
+ const answers = new WeakMap();
14
+ return (target) => {
15
+ const known = answers.get(target);
16
+ if (known !== undefined) {
17
+ return known === null ? undefined : known;
18
+ }
19
+ // Stored only after `ask` returns, so an answer that fails verification is asked for, and fails, again.
20
+ const answer = ask(target);
21
+ answers.set(target, answer ?? null);
22
+ return answer;
23
+ };
24
+ }
9
25
  /**
10
- * The reader a container should hand its resolver: verified when it came from outside.
26
+ * The reader a container should hand its resolver: verified and memoized when it came from outside.
11
27
  *
12
28
  * @remarks The decorator reader writes the metadata it later reads, so there is nothing to check and
13
29
  * nothing to pay — a container that supplies no reader of its own is left on the same code path it
14
- * has always taken. A supplied reader is a claim, and only its callers can be charged for checking.
30
+ * has always taken. A supplied reader is a claim, and only its callers can be charged for checking;
31
+ * it is asked about a class once, however many containers read through it.
15
32
  *
16
33
  * @since 0.6.0
17
34
  */
@@ -19,13 +36,18 @@ export function verifyingMetadataReader(reader) {
19
36
  if (reader === defaultMetadataReader || verifyingReaders.has(reader)) {
20
37
  return reader;
21
38
  }
39
+ const existing = wrapperByReader.get(reader);
40
+ if (existing !== undefined) {
41
+ return existing;
42
+ }
22
43
  const verifying = {
23
- getConstructorMetadata: (target) => verifyConstructorMetadata(reader, target),
24
- getLifecycleMetadata: (target) => verifyLifecycleMetadata(reader, target),
44
+ getConstructorMetadata: memoized((target) => verifyConstructorMetadata(reader, target)),
45
+ getLifecycleMetadata: memoized((target) => verifyLifecycleMetadata(reader, target)),
25
46
  ...(reader.getAccessorMetadata === undefined
26
47
  ? {}
27
- : { getAccessorMetadata: (target) => verifyAccessorMetadata(reader, target) }),
48
+ : { getAccessorMetadata: memoized((target) => verifyAccessorMetadata(reader, target)) }),
28
49
  };
29
50
  verifyingReaders.add(verifying);
51
+ wrapperByReader.set(reader, verifying);
30
52
  return verifying;
31
53
  }
@@ -0,0 +1,12 @@
1
+ /** Settles the concurrent dependencies of one async level and reports a failure in declaration order. */
2
+ /**
3
+ * Applies `apply` to every dependency's settled value, or rejects with the first failure in declaration order.
4
+ *
5
+ * @remarks Siblings start concurrently, and `Promise.all` alone would report whichever rejection
6
+ * settles first — a fact about microtask depth, not about the graph. The synchronous lanes report
7
+ * the first failing dependency in declaration order, so this does too: the happy path is `Promise.all`,
8
+ * and only a failure waits for every sibling to settle and then picks the earliest one that did not.
9
+ *
10
+ * @since 0.11.0
11
+ */
12
+ export declare function settleInOrder<Result>(pending: ReadonlyArray<unknown>, apply: (values: Array<unknown>) => Result): Promise<Result>;
@@ -0,0 +1,26 @@
1
+ /** Settles the concurrent dependencies of one async level and reports a failure in declaration order. */
2
+ /**
3
+ * Applies `apply` to every dependency's settled value, or rejects with the first failure in declaration order.
4
+ *
5
+ * @remarks Siblings start concurrently, and `Promise.all` alone would report whichever rejection
6
+ * settles first — a fact about microtask depth, not about the graph. The synchronous lanes report
7
+ * the first failing dependency in declaration order, so this does too: the happy path is `Promise.all`,
8
+ * and only a failure waits for every sibling to settle and then picks the earliest one that did not.
9
+ *
10
+ * @since 0.11.0
11
+ */
12
+ export function settleInOrder(pending, apply) {
13
+ return Promise.all(pending).then(apply, () => firstRejectionInOrder(pending));
14
+ }
15
+ function firstRejectionInOrder(pending) {
16
+ return Promise.allSettled(pending).then((results) => {
17
+ for (let index = 0; index < results.length; index += 1) {
18
+ const result = results[index];
19
+ if (result.status === "rejected") {
20
+ throw result.reason;
21
+ }
22
+ }
23
+ // Unreachable: this branch only runs once `Promise.all` has rejected, so a rejection is settled.
24
+ throw new Error("settleInOrder: a rejected fan-out settled with no rejection");
25
+ });
26
+ }
@@ -4,10 +4,10 @@
4
4
  * @remarks Versioned on the lifecycle manager plus the own registry, since `onActivation` can be
5
5
  * registered at any time and a rebind mints binding ids the memo must not keep forever.
6
6
  */
7
- import type { Binding } from "#/core/binding";
8
- import type { BindingRegistry } from "#/core/registry";
9
- import type { LifecycleManager } from "#/lifecycle/lifecycle-manager";
10
- import type { ClassIntrospector } from "#/resolution/cache/class-introspector";
7
+ import type { Binding } from "#core/binding";
8
+ import type { BindingRegistry } from "#core/registry";
9
+ import type { LifecycleManager } from "#lifecycle/lifecycle-manager";
10
+ import type { ClassIntrospector } from "#resolution/cache/class-introspector";
11
11
  /**
12
12
  * A per-binding cache of whether activation work — hooks or `@postConstruct` — is needed on resolve.
13
13
  *
@@ -17,7 +17,6 @@ export declare class ActivationNeedCache {
17
17
  #private;
18
18
  constructor(lifecycle: LifecycleManager, classes: ClassIntrospector, registry: BindingRegistry);
19
19
  /** Whether an answer the early returns could not give has had to allocate the memo. */
20
- get isMemoBuilt(): boolean;
21
20
  needsActivation<Value>(binding: Binding<Value>): boolean;
22
21
  /**
23
22
  * Settles a class binding's answer once its lifecycle metadata has actually been read, which
@@ -1,13 +1,10 @@
1
+ import { NO_ACTIVATION_STAMP } from "#core/binding";
1
2
  /**
2
3
  * A per-binding cache of whether activation work — hooks or `@postConstruct` — is needed on resolve.
3
4
  *
4
5
  * @since 0.5.0-canary.8
5
6
  */
6
7
  export class ActivationNeedCache {
7
- // Allocated by the first answer the early returns cannot give, so a hook-free container that
8
- // resolves no class or alias never pays for it.
9
- #needByBindingId;
10
- #version = -1;
11
8
  #lifecycle;
12
9
  #classes;
13
10
  #registry;
@@ -17,9 +14,6 @@ export class ActivationNeedCache {
17
14
  this.#registry = registry;
18
15
  }
19
16
  /** Whether an answer the early returns could not give has had to allocate the memo. */
20
- get isMemoBuilt() {
21
- return this.#needByBindingId !== undefined;
22
- }
23
17
  needsActivation(binding) {
24
18
  // The chain writes a binding's own hook in place with no version anything here can see, so it
25
19
  // is read fresh on every call; the memo covers only container hooks and lifecycle metadata.
@@ -32,19 +26,18 @@ export class ActivationNeedCache {
32
26
  if (lifecycleVersion === 0 && binding.kind !== "class" && binding.kind !== "alias") {
33
27
  return false;
34
28
  }
35
- // The registry version evicts entries for binding ids a rebind has retired.
36
- const version = lifecycleVersion + this.#registry.version;
37
- if (this.#version !== version) {
38
- this.#needByBindingId?.clear();
39
- this.#version = version;
29
+ // The stamp is the version pair the answer was computed under, doubled, plus the answer: a
30
+ // registry or lifecycle mutation moves the version and retires every stamp at once.
31
+ const stampBase = (lifecycleVersion + this.#registry.version) * 2;
32
+ const stamp = binding.activationStamp;
33
+ if (stamp === stampBase) {
34
+ return false;
40
35
  }
41
- const memo = (this.#needByBindingId ??= new Map());
42
- const cached = memo.get(binding.identifier);
43
- if (cached !== undefined) {
44
- return cached;
36
+ if (stamp === stampBase + 1) {
37
+ return true;
45
38
  }
46
39
  const needsActivation = binding.kind === "class" ? this.#classNeedsActivation(binding) : this.#nonClassNeedsActivation(binding);
47
- memo.set(binding.identifier, needsActivation);
40
+ binding.activationStamp = needsActivation ? stampBase + 1 : stampBase;
48
41
  return needsActivation;
49
42
  }
50
43
  /**
@@ -59,7 +52,7 @@ export class ActivationNeedCache {
59
52
  return needsActivation;
60
53
  }
61
54
  this.#classes.discoverPostConstruct(binding.target);
62
- this.#needByBindingId?.delete(binding.identifier);
55
+ binding.activationStamp = NO_ACTIVATION_STAMP;
63
56
  return this.needsActivation(binding);
64
57
  }
65
58
  // Own hooks are answered before the memo, so both computations cover the memoizable rest only.
@@ -4,11 +4,11 @@
4
4
  * @remarks Each cache links to its parent's, so a child answers from its own chain without walking
5
5
  * the container hierarchy again.
6
6
  */
7
- import type { Binding } from "#/core/binding";
8
- import type { BindingRegistry } from "#/core/registry";
9
- import type { BindingTag } from "#/core/tag";
10
- import type { Token } from "#/core/token";
11
- import type { Constructor } from "#/core/types";
7
+ import type { Binding } from "#core/binding";
8
+ import type { BindingRegistry } from "#core/registry";
9
+ import type { BindingTag } from "#core/tag";
10
+ import type { Token } from "#core/token";
11
+ import type { Constructor } from "#core/types";
12
12
  /**
13
13
  * A token's terminal binding with alias hops already folded, plus the container that owns it.
14
14
  *
@@ -34,13 +34,6 @@ export interface CollectionEntry {
34
34
  values: ReadonlyArray<unknown> | undefined;
35
35
  activationVersion: number;
36
36
  }
37
- /**
38
- * Alias folding gives up past this many hops and defers to the full resolve loop, whose
39
- * Set-based traversal detects genuine cycles exactly rather than by an arbitrary cap.
40
- *
41
- * @since 0.5.0-canary.8
42
- */
43
- export declare const ALIAS_HOP_LIMIT = 32;
44
37
  /**
45
38
  * A version-stamped cache of binding lookups by token and criterion across the container chain.
46
39
  *
@@ -1,12 +1,5 @@
1
- import { getOrInsertComputed } from "#/core/map-upsert";
2
- import { stateEpoch } from "#/core/state-epoch";
3
- /**
4
- * Alias folding gives up past this many hops and defers to the full resolve loop, whose
5
- * Set-based traversal detects genuine cycles exactly rather than by an arbitrary cap.
6
- *
7
- * @since 0.5.0-canary.8
8
- */
9
- export const ALIAS_HOP_LIMIT = 32;
1
+ import { getOrInsertComputed } from "#core/map-upsert";
2
+ import { stateEpoch } from "#core/state-epoch";
10
3
  const newTagToEntryMap = () => new Map();
11
4
  const newNameToTagMap = () => new Map();
12
5
  /**
@@ -76,15 +69,19 @@ export class BindingLookupCache {
76
69
  }
77
70
  /** `null` when the token's shape needs the full selection path. */
78
71
  defaultEntry(token) {
72
+ // The repeat hit is the whole method, small enough for a hot caller to inline; the fill is the miss.
73
+ if (token === this.#lastToken && this.chainVersion() === this.#version) {
74
+ return this.#lastEntry;
75
+ }
76
+ return this.#defaultEntryMiss(token);
77
+ }
78
+ #defaultEntryMiss(token) {
79
79
  const version = this.chainVersion();
80
80
  if (version !== this.#version) {
81
81
  this.#byToken?.clear();
82
82
  this.#version = version;
83
83
  this.#lastToken = undefined;
84
84
  }
85
- else if (token === this.#lastToken) {
86
- return this.#lastEntry;
87
- }
88
85
  let entry;
89
86
  if (this.#lastToken === undefined) {
90
87
  // First token this cache generation sees: answer from the walk and defer the map entirely.
@@ -159,7 +156,12 @@ export class BindingLookupCache {
159
156
  #findPairInChain(token, nameCriterion, tag) {
160
157
  const found = this.#registry.getPairTagged(token, nameCriterion, tag);
161
158
  if (found !== undefined) {
162
- return found.predicate !== undefined || found.kind === "alias" ? null : { binding: found, owner: this.#owner };
159
+ // A predicate candidate at this level can win the more-specific rule's first step, which this
160
+ // lane cannot weigh; an alias carries options through the full path. Either declines to selection.
161
+ if (found.kind === "alias" || this.#registry.hasPredicateCandidate(token)) {
162
+ return null;
163
+ }
164
+ return { binding: found, owner: this.#owner };
163
165
  }
164
166
  if (this.#registry.has(token)) {
165
167
  return null;
@@ -180,19 +182,30 @@ export class BindingLookupCache {
180
182
  rememberCollection(token, entry) {
181
183
  (this.#collections ??= new Map()).set(token, entry);
182
184
  }
185
+ /**
186
+ * Follows alias hops to the terminal entry, or declines with `null` on a cycle, which the full
187
+ * resolve loop reports.
188
+ *
189
+ * @remarks The origin and the current token are compared by hand and the set holds only what lies
190
+ * between them, so the common one-hop alias allocates nothing and any chain is folded exactly.
191
+ */
183
192
  #foldAliases(token) {
184
193
  let current = token;
185
- for (let hop = 0; hop < ALIAS_HOP_LIMIT; hop += 1) {
194
+ let visited;
195
+ for (;;) {
186
196
  const entry = this.#findDefaultInChain(current);
187
- if (entry === null) {
197
+ if (entry === null || entry.binding.kind !== "alias") {
198
+ return entry;
199
+ }
200
+ const next = entry.binding.target;
201
+ if (next === token || next === current || visited?.has(next) === true) {
188
202
  return null;
189
203
  }
190
- if (entry.binding.kind !== "alias") {
191
- return entry;
204
+ if (current !== token) {
205
+ (visited ??= new Set()).add(current);
192
206
  }
193
- current = entry.binding.target;
207
+ current = next;
194
208
  }
195
- return null;
196
209
  }
197
210
  #findDefaultInChain(token) {
198
211
  const fast = this.#registry.getFastDefault(token);
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * @remarks Metadata cannot change once a class is defined, so nothing here needs version stamping.
5
5
  */
6
- import type { AmbientResolution } from "#/ambient/active-container";
7
- import type { Container } from "#/container/container";
8
- import type { Constructor } from "#/core/types";
9
- import type { InjectionDescriptor } from "#/injection/descriptor";
10
- import type { ConstructorMetadata, LifecycleMetadata, MetadataReader } from "#/metadata/metadata-types";
6
+ import type { AmbientResolution } from "#ambient/active-container";
7
+ import type { Container } from "#container/container";
8
+ import type { Constructor } from "#core/types";
9
+ import type { InjectionDescriptor } from "#injection/descriptor";
10
+ import type { ConstructorMetadata, LifecycleMetadata, MetadataReader } from "#metadata/metadata-types";
11
11
  /**
12
12
  * A reader's constructor metadata for a class, verified the first time this process asks.
13
13
  *
@@ -54,6 +54,17 @@ export declare class ClassIntrospector {
54
54
  #private;
55
55
  constructor(reader: MetadataReader, container: Container, inherited: ClassIntrospector | undefined);
56
56
  constructorMetadata(target: Constructor): ConstructorMetadata | undefined;
57
+ /**
58
+ * The nearest ancestor's own constructor metadata, for a subclass that declares none of its own.
59
+ *
60
+ * @remarks Constructor metadata is never borrowed down the chain, so a subclass with an implicit
61
+ * constructor would be built with zero arguments; this lets the resolver name the base whose
62
+ * declared deps the subclass silently drops.
63
+ */
64
+ inheritedConstructorMetadata(target: Constructor): {
65
+ base: Constructor;
66
+ metadata: ConstructorMetadata;
67
+ } | undefined;
57
68
  /**
58
69
  * Whether the class has a `@postConstruct` hook, or `undefined` until {@link discoverPostConstruct}.
59
70
  *