diff --git a/examples/core-di/readme/src/validation.ts b/examples/core-di/readme/src/validation.ts index 0773d54..569e435 100644 --- a/examples/core-di/readme/src/validation.ts +++ b/examples/core-di/readme/src/validation.ts @@ -1,5 +1,5 @@ import { equal, ok, throws } from 'node:assert/strict'; -import { createServiceCollection, dependsOn, ValidationError, ValidationProblemKind } from '@shellicar/core-di'; +import { CaptivePolicy, createServiceCollection, dependsOn, ValidationError, ValidationProblemKind } from '@shellicar/core-di'; abstract class IRepository {} class Repository implements IRepository {} @@ -11,18 +11,29 @@ class Service implements IService { // validate() reads the static dependency graph and reports problems without // throwing, cheap to run in CI. A singleton that depends on a shorter-lived -// scoped service is a captive dependency. +// scoped service is a captive dependency. Under the default policy that is a +// warning: worth looking at, but the report stays valid and the build goes ahead. const services = createServiceCollection(); services.register(Repository).as(IRepository).scoped(); services.register(Service).as(IService).singleton(); const report = services.validate(); -equal(report.valid, false); -ok(report.problems.some((p) => p.kind === ValidationProblemKind.CaptiveDependency)); +equal(report.valid, true); +ok(report.warnings.some((p) => p.kind === ValidationProblemKind.CaptiveDependency)); -// buildProvider stays lenient by default; opt in with { validate: true } to -// fail fast, throwing a ValidationError that carries the problems. -throws(() => services.buildProvider({ validate: true }), ValidationError); +// The same wiring under CaptivePolicy.Strict is an error instead. Errors are what +// make a report invalid, and what { validate: true } refuses to build. +const strict = createServiceCollection({ captivePolicy: CaptivePolicy.Strict }); +strict.register(Repository).as(IRepository).scoped(); +strict.register(Service).as(IService).singleton(); + +const strictReport = strict.validate(); +equal(strictReport.valid, false); +ok(strictReport.errors.some((p) => p.kind === ValidationProblemKind.CaptiveDependency)); + +// buildProvider stays lenient by default; opt in with { validate: true } to fail +// fast, throwing a ValidationError that carries the errors. +throws(() => strict.buildProvider({ validate: true }), ValidationError); // Sound wiring validates clean. const sound = createServiceCollection(); diff --git a/packages/core-di-engine/CHANGELOG.md b/packages/core-di-engine/CHANGELOG.md index ad56062..4031b60 100644 --- a/packages/core-di-engine/CHANGELOG.md +++ b/packages/core-di-engine/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `.shadow()` on a scoped collection's builder marks a registration as allowed to win over an ancestor scope's registration of the same token, instead of colliding with it as a genuine duplicate at resolve. Present only where the collection composes a scoped lifetime. - `buildEngine`/`buildEngineAsync` accept a `bindRoot` callback, invoked once the engine is assembled but before any `.eager()` singleton is prebaked, so a composing package can bind its own root surface before construction can observe it. +- `missingTargetPolicyFor(knownTargets)` builds a missing-target policy that treats the given tokens as always satisfied, for a composing package whose engine binds some tokens itself instead of through registration. +- `ValidationProblemKind.ScopeMismatch` and `scopeMismatchPolicyFor` report a token only a scope can serve being depended on by a consumer with no scope to be served from. The token is bound by the engine rather than registered, so it is not a missing target. +- `ScopeMismatchError` is thrown when a token only a scope can serve is resolved from somewhere that has no scope. +- `ValidationProblemKind.SharingMismatch` and `sharingMismatchPolicy` report a singleton holding something shared more narrowly than itself: a scoped or resolve dependency it cannot take part in sharing. Reported as a warning whatever `CaptivePolicy` says, since the two ask different questions. ### Changed @@ -18,10 +22,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `EngineComposition.runtimeCaptivePolicy` is now required: every composition must answer whether a runtime captive throws at resolve, rather than the engine silently enforcing nothing when omitted. - Lifetime verbs are pre-commit only: once the composition stamps its default at build, a later verb throws with an error naming the commit. - Cycle-policy wording: a registration overridden by a later duplicate under `ResolveMultipleMode.LastRegistered` is now called "overridden", not "shadowed" — "shadowed" now names `.shadow()` exclusively. +- `ValidationReport` separates `errors` from `warnings`, and every `ValidationProblem` carries a `Severity`. A report is valid when it has no errors; a warning never makes one invalid. +- A captive dependency is reported as an error under `CaptivePolicy.Strict` and as a warning under `CaptivePolicy.Disposal`, so the severity comes from the policy that asked for the check. +- A lifetime reads as a word in every message that names one, instead of the enum's wire value. +- A surface token declares how far it reaches: `root` is always the root surface, `nearest` is the boundary being resolved from with the root counting as one, and `scope` is the boundary being resolved from with the root excluded. Resolving a `scope` token where there is no scope throws `ScopeMismatchError`. +- `ValidationError` carries `errors` and `warnings`, the same two lists the report has, in place of a single `problems`. ### Fixed - `IForwardResult` is exported as a real value again. +- A singleton resolves at the root, whichever boundary asked for it: its boundary, its resolution pass and its registrations are the root's, so nothing a scope owns can reach an instance the whole provider shares. +- Every singleton is constructed in a resolution pass of its own. A resolve-lifetime dependency inside a singleton is shared with that construction and nothing else, so two singletons never share one and the object graph is the same whether they were built by the same resolve or prebaked separately. +- A scope mismatch is reported on everything a singleton can reach, not only on the singleton's own edges: a singleton resolves at the root and so does everything under it, so a scoped node reached that way can never be given a scope either. +- `resolveAll` answers a surface token with the one surface its reach allows, instead of an empty list. Where the reach allows none, such as a scope-only token at the root, it still answers empty, which is what `resolveAll` says about anything it has nothing for. +- A singleton reached from more than one place is compiled once. It was compiled once per place it was reached from, which built and discarded its resolve-lifetime dependencies again each time. +- `resolveAll` answers a surface token with an empty list when the composition bound no surface, instead of a list holding `undefined`. ## [5.0.0] - 2026-07-16 diff --git a/packages/core-di-engine/changes.jsonl b/packages/core-di-engine/changes.jsonl index 78c0dc6..b42b8ef 100644 --- a/packages/core-di-engine/changes.jsonl +++ b/packages/core-di-engine/changes.jsonl @@ -15,3 +15,18 @@ {"description":"Cycle-policy wording: a registration overridden by a later duplicate under `ResolveMultipleMode.LastRegistered` is now called \"overridden\", not \"shadowed\" — \"shadowed\" now names `.shadow()` exclusively.","category":"changed"} {"description":"`IForwardResult` is exported as a real value again.","category":"fixed"} {"description":"`buildEngine`/`buildEngineAsync` accept a `bindRoot` callback, invoked once the engine is assembled but before any `.eager()` singleton is prebaked, so a composing package can bind its own root surface before construction can observe it.","category":"added"} +{"description":"`missingTargetPolicyFor(knownTargets)` builds a missing-target policy that treats the given tokens as always satisfied, for a composing package whose engine binds some tokens itself instead of through registration.","category":"added"} +{"description":"`ValidationReport` separates `errors` from `warnings`, and every `ValidationProblem` carries a `Severity`. A report is valid when it has no errors; a warning never makes one invalid.","category":"changed"} +{"description":"A captive dependency is reported as an error under `CaptivePolicy.Strict` and as a warning under `CaptivePolicy.Disposal`, so the severity comes from the policy that asked for the check.","category":"changed"} +{"description":"`ValidationProblemKind.ScopeMismatch` and `scopeMismatchPolicyFor` report a token only a scope can serve being depended on by a consumer with no scope to be served from. The token is bound by the engine rather than registered, so it is not a missing target.","category":"added"} +{"description":"`ScopeMismatchError` is thrown when a token only a scope can serve is resolved from somewhere that has no scope.","category":"added"} +{"description":"A lifetime reads as a word in every message that names one, instead of the enum's wire value.","category":"changed"} +{"description":"A surface token declares how far it reaches: `root` is always the root surface, `nearest` is the boundary being resolved from with the root counting as one, and `scope` is the boundary being resolved from with the root excluded. Resolving a `scope` token where there is no scope throws `ScopeMismatchError`.","category":"changed"} +{"description":"A singleton resolves at the root, whichever boundary asked for it: its boundary, its resolution pass and its registrations are the root's, so nothing a scope owns can reach an instance the whole provider shares.","category":"fixed"} +{"description":"Every singleton is constructed in a resolution pass of its own. A resolve-lifetime dependency inside a singleton is shared with that construction and nothing else, so two singletons never share one and the object graph is the same whether they were built by the same resolve or prebaked separately.","category":"fixed"} +{"description":"`ValidationProblemKind.SharingMismatch` and `sharingMismatchPolicy` report a singleton holding something shared more narrowly than itself: a scoped or resolve dependency it cannot take part in sharing. Reported as a warning whatever `CaptivePolicy` says, since the two ask different questions.","category":"added"} +{"description":"A scope mismatch is reported on everything a singleton can reach, not only on the singleton's own edges: a singleton resolves at the root and so does everything under it, so a scoped node reached that way can never be given a scope either.","category":"fixed"} +{"description":"`resolveAll` answers a surface token with the one surface its reach allows, instead of an empty list. Where the reach allows none, such as a scope-only token at the root, it still answers empty, which is what `resolveAll` says about anything it has nothing for.","category":"fixed"} +{"description":"A singleton reached from more than one place is compiled once. It was compiled once per place it was reached from, which built and discarded its resolve-lifetime dependencies again each time.","category":"fixed"} +{"description":"`ValidationError` carries `errors` and `warnings`, the same two lists the report has, in place of a single `problems`.","category":"changed"} +{"description":"`resolveAll` answers a surface token with an empty list when the composition bound no surface, instead of a list holding `undefined`.","category":"fixed"} diff --git a/packages/core-di-engine/src/enums.ts b/packages/core-di-engine/src/enums.ts index 5fd0515..ff21aaa 100644 --- a/packages/core-di-engine/src/enums.ts +++ b/packages/core-di-engine/src/enums.ts @@ -58,10 +58,25 @@ export enum RuntimeCaptivePolicy { Throw = 'THROW', } +/** + * How much a validation problem matters. An error means the wiring cannot be + * trusted to build: `validate()` reports the composition invalid, and a + * `buildProvider({ validate: true })` refuses. A warning is a hazard worth + * looking at that never blocks a build. + */ +export enum Severity { + Error = 'ERROR', + Warning = 'WARNING', +} + export enum ValidationProblemKind { NoIdentity = 'NO_IDENTITY', MissingTarget = 'MISSING_TARGET', CaptiveDependency = 'CAPTIVE_DEPENDENCY', + /** A singleton holding something shared more narrowly than itself. One instance serves the whole provider, so a per-scope or per-resolve dependency cannot be the instance its other consumers share, and what the singleton ends up holding would otherwise depend on how it happened to be built. */ + SharingMismatch = 'SHARING_MISMATCH', + /** A token that only a scope can serve, reached by a consumer that has no scope to be served from. The token is registered nowhere because the engine binds it: it is not missing, it is unsatisfiable for this consumer. */ + ScopeMismatch = 'SCOPE_MISMATCH', Cycle = 'CYCLE', AsyncThroughSyncPath = 'ASYNC_THROUGH_SYNC_PATH', } diff --git a/packages/core-di-engine/src/errors.ts b/packages/core-di-engine/src/errors.ts index 9897530..3ecce74 100644 --- a/packages/core-di-engine/src/errors.ts +++ b/packages/core-di-engine/src/errors.ts @@ -51,6 +51,19 @@ export class CircularDependencyError extends ServiceError { } } +/** + * Thrown resolving a token only a scope can serve from somewhere that has no scope. + * Distinct from {@link UnregisteredServiceError}: the token is bound by the engine + * rather than registered, so nothing is missing — the boundary asking for it just + * cannot be served. + */ +export class ScopeMismatchError extends ServiceError { + name = 'ScopeMismatchError'; + constructor(identifier: ServiceIdentifier) { + super(`Resolving ${identifier.name} outside a scope: only a scope can serve it, and the root provider is not a scope. Open one with createScope().`); + } +} + export class ScopedSingletonRegistrationError extends BuilderError { name = 'ScopedSingletonRegistrationError'; constructor() { @@ -72,15 +85,23 @@ export class InvalidImplementationError extends BuilderError { } } +/** + * Thrown by a build that was asked to validate. It carries the report's two lists as the + * report itself does: the errors are what refused the build, and the warnings are what + * the same run had to say about wiring that would have built. + */ export class ValidationError extends ServiceError { name = 'ValidationError'; - constructor(public readonly problems: ValidationProblem[]) { - super(ValidationError.getErrorMessage(problems)); + constructor( + public readonly errors: readonly ValidationProblem[], + public readonly warnings: readonly ValidationProblem[] = [], + ) { + super(ValidationError.getErrorMessage(errors)); } - static getErrorMessage(problems: ValidationProblem[]): string { - const detail = problems.map((problem) => `- ${problem.kind}: ${problem.message}`).join('\n'); - return `Service wiring validation failed with ${problems.length} problem(s):\n${detail}`; + static getErrorMessage(errors: readonly ValidationProblem[]): string { + const detail = errors.map((problem) => `- ${problem.kind}: ${problem.message}`).join('\n'); + return `Service wiring validation failed with ${errors.length} error(s):\n${detail}`; } } diff --git a/packages/core-di-engine/src/index.ts b/packages/core-di-engine/src/index.ts index 35d6546..d9cb289 100644 --- a/packages/core-di-engine/src/index.ts +++ b/packages/core-di-engine/src/index.ts @@ -10,7 +10,7 @@ // composed by presets: core-di is the full composition, core-di-lite the // focused one. Not a stable public API; presets compose from it. export { dependsOn } from './dependsOn'; -export { CaptivePolicy, Lifetime, LogLevel, ResolveMultipleMode, RuntimeCaptivePolicy, ValidationProblemKind } from './enums'; +export { CaptivePolicy, Lifetime, LogLevel, ResolveMultipleMode, RuntimeCaptivePolicy, Severity, ValidationProblemKind } from './enums'; export { BuilderError, CaptiveDependencyError, @@ -20,6 +20,7 @@ export { InvalidServiceIdentifierError, MultipleRegistrationError, ScopedSingletonRegistrationError, + ScopeMismatchError, SelfDependencyError, ServiceCreationError, ServiceError, @@ -27,7 +28,19 @@ export { ValidationError, } from './errors'; export { IForwardBuilder, IForwardResult, IResolutionScope, IScopedForwardBuilder } from './interfaces'; -export { type Boundary, type BuildEngineOptions, buildEngine, buildEngineAsync, type DisposalSink, type Engine, type EngineComposition, type EngineFor, type Scope, type ScopeOverlay } from './private/boundaryEngine'; +export { + type Boundary, + type BuildEngineOptions, + buildEngine, + buildEngineAsync, + type DisposalSink, + type Engine, + type EngineComposition, + type EngineFor, + type Scope, + type ScopeOverlay, + type SurfaceReach, +} from './private/boundaryEngine'; export { createCollection, lifetimeVerbNames } from './private/composableBuilder'; export { DesignDependenciesKey } from './private/constants'; export { createDisposal } from './private/disposal'; @@ -41,7 +54,7 @@ export * from './private/messages'; export { getMetadata, tagFieldMetadata } from './private/metadata'; export { createNaiveStrategy } from './private/naiveStrategy'; export { createPlanStrategy } from './private/planStrategy'; -export { asyncThroughSyncPathPolicy, captivePolicyFor, cyclePolicy, disposalCaptive, missingTargetPolicy, runGraphPolicies, strictCaptive } from './private/policies'; +export { asyncThroughSyncPathPolicy, captivePolicyFor, cyclePolicy, disposalCaptive, missingTargetPolicy, missingTargetPolicyFor, runGraphPolicies, scopeMismatchPolicyFor, sharingMismatchPolicy, strictCaptive } from './private/policies'; export { pushBucket } from './private/pushBucket'; export type { EngineView, Outcome, ResolutionStrategy, ResolvedField, StrategyFactory, StrategyKit } from './private/strategy'; export type { diff --git a/packages/core-di-engine/src/private/boundaryEngine.ts b/packages/core-di-engine/src/private/boundaryEngine.ts index 54f3bb2..3409a82 100644 --- a/packages/core-di-engine/src/private/boundaryEngine.ts +++ b/packages/core-di-engine/src/private/boundaryEngine.ts @@ -1,5 +1,5 @@ import { Lifetime, ResolveMultipleMode, RuntimeCaptivePolicy } from '../enums'; -import { CaptiveDependencyError, CircularDependencyError, InvalidOperationError, MultipleRegistrationError, ServiceCreationError, UnregisteredServiceError } from '../errors'; +import { CaptiveDependencyError, CircularDependencyError, InvalidOperationError, MultipleRegistrationError, ScopeMismatchError, ServiceCreationError, UnregisteredServiceError } from '../errors'; import type { IResolutionScope } from '../interfaces'; import type { DescriptorMap, ServiceIdentifier, ServiceRegistration, SourceType } from '../types'; import { followForward } from './followForward'; @@ -12,6 +12,19 @@ const isAsyncNode = (node: GraphNode): node is AsyncNode => node.createInstanceA const EMPTY_BUCKET: readonly GraphNode[] = []; +/** + * How far a surface token reaches: which boundaries can serve it. + * + * - `root` — always the root surface, from anywhere. + * - `nearest` — the boundary being resolved from, and the root counts as one. + * - `scope` — the boundary being resolved from, and the root does not count. + * + * `nearest` and `scope` differ only at the root, which is the whole distinction: + * a token naming "wherever I am" is answerable everywhere, and a token naming the + * scope has no answer where there is no scope. + */ +export type SurfaceReach = 'root' | 'nearest' | 'scope'; + export type EngineComposition = { readonly features?: LifetimeFeatures; /** @@ -29,7 +42,7 @@ export type EngineComposition = { */ readonly prebakeSingletons?: boolean; readonly disposal?: DisposalSink; - readonly surfaceTokens?: ReadonlyMap, 'root' | 'boundary'>; + readonly surfaceTokens?: ReadonlyMap, SurfaceReach>; /** * Whether a runtime captive (a singleton pulling a scoped instance through an * opaque factory) throws at resolve. Required: the engine holds no default — @@ -95,9 +108,20 @@ const setupEngine = (services: DescriptorMap, composition: EngineComposition, op return lifetime; }; - const surfaceAt = (token: ServiceIdentifier): 'root' | 'boundary' | undefined => composition.surfaceTokens?.get(token); + const surfaceAt = (token: ServiceIdentifier): SurfaceReach | undefined => composition.surfaceTokens?.get(token); - const surfaceValue = (at: 'root' | 'boundary', boundary: Boundary): unknown => surfaces.get(at === 'root' ? rootBoundary.id : boundary.id); + // The root is a boundary like any other, so it has a surface bound to it and would + // answer for a `scope` token as readily as a real scope does. Refusing here is what + // makes the reach mean anything: the root has no scope to give. + const surfaceValue = (at: SurfaceReach, boundary: Boundary, token: ServiceIdentifier): unknown => { + if (at === 'root') { + return surfaces.get(rootBoundary.id); + } + if (at === 'scope' && boundary.id === rootBoundary.id) { + throw new ScopeMismatchError(token); + } + return surfaces.get(boundary.id); + }; const guardToken = (token: ServiceIdentifier, nodes: readonly GraphNode[]): unknown | undefined => { // No shadow anywhere in the bucket: unchanged from before shadow existed. @@ -212,11 +236,20 @@ const setupEngine = (services: DescriptorMap, composition: EngineComposition, op return instance; }; + // A singleton lives at the root: it is announced for disposal there, and one instance + // serves every boundary. So its dependencies resolve there too, whatever boundary + // happened to ask for it first. Without this, the first caller decides what a + // provider-wide instance holds, and a scope's surfaces leak into an object that + // outlives the scope. + const rootPass = (): { readonly env: Env; readonly boundary: Boundary } => ({ env: freshPass(rootBase), boundary: rootBoundary }); + const strategy: ResolutionStrategy = composition.strategy({ lifetimeOf, isCached, surfaceAt, surfaceValue, + rootPass, + rootView: () => rootView, guardToken, nodeForToken, ownerOf, @@ -251,7 +284,7 @@ const setupEngine = (services: DescriptorMap, composition: EngineComposition, op const resolveValue = (view: EngineView, token: ServiceIdentifier, env: Env, boundary: Boundary): unknown => { const at = surfaceAt(token); if (at !== undefined) { - return surfaceValue(at, boundary); + return surfaceValue(at, boundary, token); } const node = nodeForToken(view, token); guardNode(node, token); @@ -263,6 +296,21 @@ const setupEngine = (services: DescriptorMap, composition: EngineComposition, op }; const resolveManyValue = (view: EngineView, token: ServiceIdentifier, env: Env, boundary: Boundary): unknown[] => { + // A surface is bound rather than registered, so it would otherwise be invisible here + // and every one of them would come back empty. It is exactly one instance where its + // reach allows, and none where it does not — which is what resolveAll says about + // anything it has nothing for, rather than the refusal the single door gives. + const at = surfaceAt(token); + if (at !== undefined) { + if (at === 'scope' && boundary.id === rootBoundary.id) { + return []; + } + // Nothing bound is nothing to list, the same answer as a reach that allows none: + // a composition that never bound its surface has none to give, not one that is + // undefined. + const surface = surfaceValue(at, boundary, token); + return surface === undefined ? [] : [surface]; + } const descriptors = view.services.get(token) ?? []; return descriptors.map((descriptor) => { const node = followForward(view.index, descriptor); diff --git a/packages/core-di-engine/src/private/graph.ts b/packages/core-di-engine/src/private/graph.ts index 4feac29..ec44089 100644 --- a/packages/core-di-engine/src/private/graph.ts +++ b/packages/core-di-engine/src/private/graph.ts @@ -1,6 +1,7 @@ -import type { Lifetime } from '../enums'; +import { Lifetime } from '../enums'; import { CircularDependencyError, SelfDependencyError, UnregisteredServiceError } from '../errors'; import type { DescriptorMap, ServiceIdentifier, SourceType } from '../types'; +import type { SurfaceReach } from './boundaryEngine'; import { DesignDependenciesKey } from './constants'; import { followForward } from './followForward'; import { buildPlanMissingFacts } from './messages'; @@ -171,6 +172,18 @@ export type { OwnerIndex } from './strategy'; import type { OwnerIndex } from './strategy'; +/** + * `pass` names which resolution pass a step belongs to. A plan is flat, so a + * singleton's dependencies are slots of their own, evaluated before the step that + * consumes them: without the mark they would be evaluated against whichever boundary + * replayed the plan, and the singleton would hold whatever that boundary gave it. + * + * `undefined` is the caller's pass. Every singleton opens one of its own, at the root, + * and its subtree shares it: a singleton is constructed once, so a resolve-lifetime + * dependency inside it is shared with that construction and nothing else. Two + * singletons never share one, whether they were built by the same resolve or prebaked + * separately, so what a singleton holds never depends on how it came to be built. + */ export type PlanStep = | { readonly kind: 'build'; @@ -179,6 +192,7 @@ export type PlanStep = readonly lifetime: Lifetime; readonly fields: readonly { readonly field: string; readonly slot: number }[]; readonly args: readonly number[]; + readonly pass: number | undefined; } | { readonly kind: 'error'; @@ -188,7 +202,8 @@ export type PlanStep = | { readonly kind: 'surface'; readonly token: ServiceIdentifier; - readonly at: 'root' | 'boundary'; + readonly at: SurfaceReach; + readonly pass: number | undefined; }; export type Plan = readonly PlanStep[]; @@ -247,20 +262,50 @@ export const concreteNode = (index: OwnerIndex, token: ServiceIdentifier Lifetime, isCached: (lifetime: Lifetime) => boolean, - surfaceAt?: (token: ServiceIdentifier) => 'root' | 'boundary' | undefined, + surfaceAt?: (token: ServiceIdentifier) => SurfaceReach | undefined, guardToken?: (token: ServiceIdentifier, nodes: readonly GraphNode[]) => unknown | undefined, + rootRegistrations?: { readonly graph: Graph; readonly index: OwnerIndex }, ): Plan => { + const registrationsFor = (pass: number | undefined): { readonly graph: Graph; readonly index: OwnerIndex } => (pass === undefined ? { graph, index } : (rootRegistrations ?? { graph, index })); const steps: PlanStep[] = []; - const sharedSlot = new Map(); + // Keyed by pass as well as node: the same cached node reached from two passes is two + // instances, resolved against two different boundaries, so it cannot share one slot. + const sharedSlot = new Map>(); + const slotsFor = (pass: number | undefined): Map => { + let slots = sharedSlot.get(pass); + if (slots === undefined) { + slots = new Map(); + sharedSlot.set(pass, slots); + } + return slots; + }; + // A singleton's pass belongs to the singleton, not to the place it was reached from: + // reaching the same one twice must land on the same pass, or its slot memo cannot hit + // and the plan carries a second copy of everything under it. + const passOf = new Map(); + let passes = 0; + const passFor = (node: GraphNode): number => { + let pass = passOf.get(node); + if (pass === undefined) { + pass = passes++; + passOf.set(node, pass); + } + return pass; + }; - const ownerOf = (node: GraphNode): ServiceIdentifier => { - const facts = graph.get(node); + const ownerOf = (node: GraphNode, pass: number | undefined): ServiceIdentifier => { + const facts = registrationsFor(pass).graph.get(node) ?? graph.get(node); if (facts === undefined) { throw new Error(buildPlanMissingFacts); } @@ -272,27 +317,32 @@ export const buildPlan = ( return steps.length - 1; }; - const emitToken = (identifier: ServiceIdentifier, path: ReadonlySet): number => { + const emitToken = (identifier: ServiceIdentifier, path: ReadonlySet, pass: number | undefined): number => { const at = surfaceAt?.(identifier); if (at !== undefined) { - return push({ kind: 'surface', token: identifier, at }); + return push({ kind: 'surface', token: identifier, at, pass }); } - const guardError = guardToken?.(identifier, index.get(identifier) ?? []); + const registrations = registrationsFor(pass); + const guardError = guardToken?.(identifier, registrations.index.get(identifier) ?? []); if (guardError !== undefined) { return push({ kind: 'error', token: identifier, error: guardError }); } - const node = concreteNode(index, identifier); + const node = concreteNode(registrations.index, identifier); if (node === undefined) { return push({ kind: 'error', token: identifier, error: new UnregisteredServiceError(identifier) }); } - return emitNode(node, path); + return emitNode(node, path, pass); }; - const emitNode = (node: GraphNode, path: ReadonlySet): number => { - const token = ownerOf(node); + const emitNode = (node: GraphNode, path: ReadonlySet, callerPass: number | undefined): number => { const lifetime = lifetimeOf(node); + // Every singleton opens its own pass at the root, nested ones included: one + // construction, one pass, shared by its subtree and nothing else. + const pass = lifetime === Lifetime.Singleton ? passFor(node) : callerPass; + const token = ownerOf(node, pass); const cached = isCached(lifetime); - const existing = sharedSlot.get(node); + const slots = slotsFor(pass); + const existing = slots.get(node); if (cached && existing !== undefined) { return existing; } @@ -307,7 +357,7 @@ export const buildPlan = ( fields.push({ field, slot: push({ kind: 'error', token, error: new SelfDependencyError() }) }); continue; } - fields.push({ field, slot: emitToken(identifier, nextPath) }); + fields.push({ field, slot: emitToken(identifier, nextPath, pass) }); } const args: number[] = []; for (const identifier of node.declaredDeps ?? []) { @@ -315,16 +365,16 @@ export const buildPlan = ( args.push(push({ kind: 'error', token, error: new SelfDependencyError() })); continue; } - args.push(emitToken(identifier, nextPath)); + args.push(emitToken(identifier, nextPath, pass)); } - const slot = push({ kind: 'build', node, token, lifetime, fields, args }); + const slot = push({ kind: 'build', node, token, lifetime, fields, args, pass }); if (cached) { - sharedSlot.set(node, slot); + slots.set(node, slot); } return slot; }; - emitNode(rootNode, new Set()); + emitNode(rootNode, new Set(), undefined); return steps; }; diff --git a/packages/core-di-engine/src/private/messages.ts b/packages/core-di-engine/src/private/messages.ts index 926a9e7..5c3731a 100644 --- a/packages/core-di-engine/src/private/messages.ts +++ b/packages/core-di-engine/src/private/messages.ts @@ -1,5 +1,11 @@ import type { Lifetime } from '../enums'; +// A lifetime reads as a word inside a sentence, not as the enum's wire value: every +// message below renders one through here, so prose never mixes `singleton` with +// `SINGLETON`. The bracketed tag in printGraph's graph dump is not prose and keeps +// the wire value. +const asWord = (lifetime: Lifetime): string => lifetime.toLowerCase(); + /** * Every guard and policy message in one place, one export per message. Individual * exports rather than a single Messages object: an object literal is retained whole @@ -14,13 +20,20 @@ export const forwardIsTerminal = 'A forward registration is terminal: it is a pu export const nodeWithoutLifetime = (implementationName: string): string => `${implementationName} reached the engine without a lifetime; the composition must stamp a concrete lifetime on every registration before building.`; export const noDeclaredIdentity = (implementationName: string): string => `${implementationName} was registered without a declared identity (no .as() or .asSelf())`; -export const lifetimeAlreadySet = (lifetime: Lifetime): string => `A lifetime (${lifetime}) is already set on this registration; a registration has exactly one lifetime.`; +export const lifetimeAlreadySet = (lifetime: Lifetime): string => `A lifetime (${asWord(lifetime)}) is already set on this registration; a registration has exactly one lifetime.`; export const shadowAlreadySet = 'shadow() is already set on this registration; a registration can shadow an ancestor at most once.'; -export const lifetimeAfterCommit = (lifetime: Lifetime): string => `This registration was already committed with the default lifetime (${lifetime}) when its provider or scope was built. Call lifetime verbs before building or resolving.`; +export const lifetimeAfterCommit = (lifetime: Lifetime): string => `This registration was already committed with the default lifetime (${asWord(lifetime)}) when its provider or scope was built. Call lifetime verbs before building or resolving.`; export const syncBuildOfAsyncFactory = (tokenName: string): string => `Cannot build '${tokenName}' synchronously: it is registered with an async factory (usingAsync). Use buildProviderAsync to build a provider with async registrations.`; export const asyncFactoryOnSyncPath = (tokenName: string): string => `Cannot construct '${tokenName}' synchronously: its factory is async (usingAsync), and only a singleton settles at the async build boundary. Register it as a singleton and build with buildProviderAsync.`; export const dependencyCycle = (names: readonly string[]): string => `Dependency cycle: ${names.join(' -> ')} -> ${names[0]}`; export const dependencyCycleOverridden = (names: readonly string[]): string => `${dependencyCycle(names)} (through a registration overridden for resolve(); reachable via resolveAll(), which walks every registration)`; export const missingTarget = (fromName: string | undefined, missingName: string): string => `${fromName} depends on ${missingName}, which is not registered`; -export const captiveDependency = (ownerName: string | undefined, depName: string | undefined, lifetime: Lifetime): string => `${ownerName} (singleton) captures ${depName} (${lifetime}) in its dependency tree, a captive dependency`; -export const asyncThroughSyncPath = (ownerName: string | undefined, lifetime: Lifetime | undefined): string => `${ownerName} is an async factory resolving under ${lifetime ?? 'the default lifetime'}, an async factory reachable through a synchronous path; register it as a singleton and build with buildProviderAsync`; +export const scopeMismatchSingleton = (ownerName: string | undefined, tokenName: string): string => `${ownerName} (singleton) depends on ${tokenName}, which only a scope can serve: one instance serves the whole provider, so it outlives every scope and no boundary can give it one`; +export const scopeMismatchRootReachable = (ownerName: string | undefined, tokenName: string, lifetime: Lifetime): string => `${ownerName} (${asWord(lifetime)}) depends on ${tokenName}, which only a scope can serve: resolved from the root it receives the root provider, which is not a scope`; +export const scopeMismatchUnderSingleton = (ownerName: string | undefined, tokenName: string, lifetime: Lifetime): string => + `${ownerName} (${asWord(lifetime)}) depends on ${tokenName}, which only a scope can serve, and is reached from a singleton: a singleton resolves at the root, so everything under it does too and no scope is ever available`; +export const sharingMismatch = (ownerName: string | undefined, depName: string | undefined, lifetime: Lifetime): string => + `${ownerName} (singleton) depends on ${depName} (${asWord(lifetime)}), which is shared more narrowly than a singleton: one instance serves the whole provider, so it cannot take part in that sharing and would hold whichever instance happened to build it`; +export const captiveDependency = (ownerName: string | undefined, depName: string | undefined, lifetime: Lifetime): string => `${ownerName} (singleton) captures ${depName} (${asWord(lifetime)}) in its dependency tree, a captive dependency`; +export const asyncThroughSyncPath = (ownerName: string | undefined, lifetime: Lifetime | undefined): string => + `${ownerName} is an async factory resolving under ${lifetime === undefined ? 'the default lifetime' : asWord(lifetime)}, an async factory reachable through a synchronous path; register it as a singleton and build with buildProviderAsync`; diff --git a/packages/core-di-engine/src/private/naiveStrategy.ts b/packages/core-di-engine/src/private/naiveStrategy.ts index d865944..9254ead 100644 --- a/packages/core-di-engine/src/private/naiveStrategy.ts +++ b/packages/core-di-engine/src/private/naiveStrategy.ts @@ -1,3 +1,4 @@ +import { Lifetime } from '../enums'; import { CircularDependencyError, SelfDependencyError } from '../errors'; import type { DescriptorMap, ServiceIdentifier, SourceType } from '../types'; import type { Boundary } from './boundaryEngine'; @@ -39,9 +40,15 @@ export const createNaiveStrategy = (kit: StrategyKit): ResolutionStrategy => { const ownerFor = (view: EngineView, node: GraphNode): ServiceIdentifier => (view.data as NaiveView).get(node) ?? kit.ownerOf(view, node); - const nodeValue = (view: EngineView, node: GraphNode, env: Env, boundary: Boundary, path: ReadonlySet): unknown => { - const token = ownerFor(view, node); + const nodeValue = (callerView: EngineView, node: GraphNode, callerEnv: Env, callerBoundary: Boundary, path: ReadonlySet): unknown => { const lifetime = kit.lifetimeOf(node); + // A singleton is resolved at the root, whoever asked: it outlives every scope, so + // nothing a scope owns may reach it, neither the scope's surfaces nor its + // registrations. Its whole subtree inherits that pass and those registrations. + const atRoot = lifetime === Lifetime.Singleton; + const { env, boundary } = atRoot ? kit.rootPass() : { env: callerEnv, boundary: callerBoundary }; + const view = atRoot ? kit.rootView() : callerView; + const token = ownerFor(view, node); const held = kit.heldErrorFor(node); if (held !== undefined) { throw held; @@ -55,7 +62,7 @@ export const createNaiveStrategy = try { const at = kit.surfaceAt(identifier); if (at !== undefined) { - return kit.surfaceValue(at, boundary); + return kit.surfaceValue(at, boundary, identifier); } return nodeValue(view, kit.nodeForToken(view, identifier), env, boundary, nextPath); } catch (err) { diff --git a/packages/core-di-engine/src/private/planStrategy.ts b/packages/core-di-engine/src/private/planStrategy.ts index d91cec2..0d3ff51 100644 --- a/packages/core-di-engine/src/private/planStrategy.ts +++ b/packages/core-di-engine/src/private/planStrategy.ts @@ -25,7 +25,8 @@ export const createPlanStrategy = const { graph, planCache } = dataOf(view); let plan = planCache.get(node); if (plan === undefined) { - plan = buildPlan(graph, view.index, node, kit.lifetimeOf, kit.isCached, kit.surfaceAt, kit.guardToken); + const root = kit.rootView(); + plan = buildPlan(graph, view.index, node, kit.lifetimeOf, kit.isCached, kit.surfaceAt, kit.guardToken, { graph: dataOf(root).graph, index: root.index }); planCache.set(node, plan); } return plan; @@ -35,9 +36,20 @@ export const createPlanStrategy = if (step.kind === 'error') { return failed(step.error); } + // A surface can refuse the boundary it is asked at, and every failure in a plan + // travels as a failed slot rather than a throw: a prebaked node holds its failure + // for its first resolve, which a throw escaping here would skip. + // + // Resolved at replay, never at compile: one plan is replayed at every boundary, + // so which surface serves it is not knowable when the plan is built. if (step.kind === 'surface') { - return ok(kit.surfaceValue(step.at, boundary)); + try { + return ok(kit.surfaceValue(step.at, boundary, step.token)); + } catch (err) { + return failed(err); + } } + if (step.lifetime === Lifetime.Singleton) { const held = kit.heldErrorFor(step.node); if (held !== undefined) { @@ -72,8 +84,23 @@ export const createPlanStrategy = createView: (services: DescriptorMap): PlanView => ({ graph: deriveFacts(services), planCache: new Map() }), instanceFor: (view, node, env, boundary): Outcome => { const locals: Outcome[] = []; + // One pass per singleton, made when its first step runs: a singleton is one + // construction, so its subtree shares a pass with it and with nothing else. + const roots = new Map(); + const passFor = (step: PlanStep): { readonly env: Env; readonly boundary: Boundary; readonly view: EngineView } => { + if (step.kind === 'error' || step.pass === undefined) { + return { env, boundary, view }; + } + let root = roots.get(step.pass); + if (root === undefined) { + root = { ...kit.rootPass(), view: kit.rootView() }; + roots.set(step.pass, root); + } + return root; + }; for (const step of planFor(view, node)) { - locals.push(runStep(view, step, locals, env, boundary)); + const pass = passFor(step); + locals.push(runStep(pass.view, step, locals, pass.env, pass.boundary)); } return locals[locals.length - 1]; }, diff --git a/packages/core-di-engine/src/private/policies.ts b/packages/core-di-engine/src/private/policies.ts index 01b9d1e..4f743a5 100644 --- a/packages/core-di-engine/src/private/policies.ts +++ b/packages/core-di-engine/src/private/policies.ts @@ -1,8 +1,8 @@ -import { CaptivePolicy, Lifetime, ValidationProblemKind } from '../enums'; -import type { ValidationProblem } from '../types'; +import { CaptivePolicy, Lifetime, Severity, ValidationProblemKind } from '../enums'; +import type { ServiceIdentifier, SourceType, ValidationProblem } from '../types'; import { detectCycles, findUnregisteredEdges, indexByOwner, reachableFrom, winnerOf } from './graph'; -import { asyncThroughSyncPath, captiveDependency, dependencyCycle, dependencyCycleOverridden, missingTarget } from './messages'; -import type { Graph, GraphPolicy } from './types'; +import { asyncThroughSyncPath, captiveDependency, dependencyCycle, dependencyCycleOverridden, missingTarget, scopeMismatchRootReachable, scopeMismatchSingleton, scopeMismatchUnderSingleton, sharingMismatch } from './messages'; +import type { Graph, GraphNode, GraphPolicy } from './types'; export const cyclePolicy: GraphPolicy = (graph) => { const cycles = detectCycles(graph); @@ -26,22 +26,123 @@ export const cyclePolicy: GraphPolicy = (graph) => { const names = cycle.map((node) => graph.get(node)?.owner.name ?? ''); return { kind: ValidationProblemKind.Cycle, + severity: Severity.Error, message: cycle.some(isOverridden) ? dependencyCycleOverridden(names) : dependencyCycle(names), }; }); }; -export const missingTargetPolicy: GraphPolicy = (graph) => - findUnregisteredEdges(graph).map((edge) => ({ - kind: ValidationProblemKind.MissingTarget, - message: missingTarget(graph.get(edge.from)?.owner.name, edge.missing.name), - })); +// A token can be a dependency edge target without ever being registered: the +// engine binds surface tokens (IServiceProvider et al.) itself at build, outside +// the descriptor map deriveFacts walks. Those tokens are always satisfied, so a +// caller passes them in to keep validate() agreeing with what buildProvider can +// actually resolve. +export const missingTargetPolicyFor = + (knownTargets: ReadonlySet>): GraphPolicy => + (graph) => + findUnregisteredEdges(graph) + .filter((edge) => !knownTargets.has(edge.missing)) + .map((edge) => ({ + kind: ValidationProblemKind.MissingTarget, + severity: Severity.Error, + message: missingTarget(graph.get(edge.from)?.owner.name, edge.missing.name), + })); + +export const missingTargetPolicy: GraphPolicy = missingTargetPolicyFor(new Set()); + +const scopeMismatchMessage = (lifetime: Lifetime, neverServable: boolean, ownerName: string | undefined, tokenName: string): string => { + if (lifetime === Lifetime.Singleton) { + return scopeMismatchSingleton(ownerName, tokenName); + } + return neverServable ? scopeMismatchUnderSingleton(ownerName, tokenName, lifetime) : scopeMismatchRootReachable(ownerName, tokenName, lifetime); +}; + +/** + * A token only a scope can serve, depended on by a consumer that has no scope to be + * served from. `servedBy` is the one lifetime that always resolves inside a scope. + * + * A singleton is an error, and so is anything a singleton can reach: a singleton and + * everything under it resolves at the root, whatever the reached node's own lifetime + * says, so no boundary can ever give it a scope and it can never construct. That is the + * same shape as a missing target, a guaranteed failure deferred to resolve. Any other + * consumer is a warning: resolved inside a scope it is served correctly, and only the + * root is wrong, which no static read can tell apart. + * + * The token is never registered (the engine binds it), so `missingTargetPolicy` must + * still treat it as known: this is not a missing target, it is one that cannot be + * satisfied for this consumer. + */ +export const scopeMismatchPolicyFor = + (token: ServiceIdentifier, servedBy: Lifetime): GraphPolicy => + (graph) => { + const atRoot = new Set(); + for (const [node, facts] of graph) { + if (facts.lifetime === Lifetime.Singleton) { + atRoot.add(node); + for (const reached of reachableFrom(graph, node)) { + atRoot.add(reached); + } + } + } + + const problems: ValidationProblem[] = []; + for (const [node, facts] of graph) { + // A forward carries no lifetime of its own; its consumers are judged instead. + if (facts.lifetime === undefined || !facts.deps.includes(token)) { + continue; + } + const neverServable = atRoot.has(node); + if (facts.lifetime === servedBy && !neverServable) { + continue; + } + problems.push({ + kind: ValidationProblemKind.ScopeMismatch, + severity: neverServable ? Severity.Error : Severity.Warning, + message: scopeMismatchMessage(facts.lifetime, neverServable, facts.owner.name, token.name), + }); + } + return problems; + }; + +/** + * A singleton may only hold what is shared at least as widely as itself, or not shared + * at all: another singleton, a transient (nothing shares it, so no contract breaks), or + * a provider-lived surface. A scoped or resolve dependency is shared with a set the + * singleton cannot belong to, so it takes a private instance wearing a shared + * contract — and which instance that is would depend on how it came to be built. + * + * A warning, and not governed by `CaptivePolicy`: every singleton resolves in a pass of + * its own, so what it holds is deterministic and nothing misbehaves — the consumer + * simply gets a private instance where it asked for a shared one. It sits beside the + * captive report rather than replacing it, since a scoped dependency is both this and a + * disposal hazard, and the hazard is what carries the severity. + */ +export const sharingMismatchPolicy: GraphPolicy = (graph) => { + const problems: ValidationProblem[] = []; + for (const [node, facts] of graph) { + if (facts.lifetime !== Lifetime.Singleton) { + continue; + } + for (const dep of reachableFrom(graph, node)) { + const depFacts = graph.get(dep); + const lifetime = depFacts?.lifetime; + if (lifetime === Lifetime.Scoped || lifetime === Lifetime.Resolve) { + problems.push({ + kind: ValidationProblemKind.SharingMismatch, + severity: Severity.Warning, + message: sharingMismatch(facts.owner.name, depFacts?.owner.name, lifetime), + }); + } + } + } + return problems; +}; // Lifetimes arrive stamped: the composition supplies a concrete lifetime on every // non-forward node before the graph is derived. Only a forward carries undefined // here, and a forward is judged through its target node, not itself. const captivePolicy = - (isCaptured: (lifetime: Lifetime) => boolean): GraphPolicy => + (isCaptured: (lifetime: Lifetime) => boolean, severity: Severity): GraphPolicy => (graph) => { const problems: ValidationProblem[] = []; for (const [node, facts] of graph) { @@ -54,6 +155,7 @@ const captivePolicy = if (lifetime != null && isCaptured(lifetime)) { problems.push({ kind: ValidationProblemKind.CaptiveDependency, + severity, message: captiveDependency(facts.owner.name, depFacts?.owner.name, lifetime), }); } @@ -62,9 +164,11 @@ const captivePolicy = return problems; }; -export const strictCaptive: GraphPolicy = captivePolicy((lifetime) => lifetime !== Lifetime.Singleton); +// Strict errors: strictness a build ignores is not strict. Disposal warns: the +// scoped captive is a real use-after-dispose hazard, but the composition runs. +export const strictCaptive: GraphPolicy = captivePolicy((lifetime) => lifetime !== Lifetime.Singleton, Severity.Error); -export const disposalCaptive: GraphPolicy = captivePolicy((lifetime) => lifetime === Lifetime.Scoped); +export const disposalCaptive: GraphPolicy = captivePolicy((lifetime) => lifetime === Lifetime.Scoped, Severity.Warning); export const asyncThroughSyncPathPolicy: GraphPolicy = (graph) => { const problems: ValidationProblem[] = []; @@ -72,6 +176,7 @@ export const asyncThroughSyncPathPolicy: GraphPolicy = (graph) => { if (facts.isAsync && facts.lifetime !== Lifetime.Singleton) { problems.push({ kind: ValidationProblemKind.AsyncThroughSyncPath, + severity: Severity.Error, message: asyncThroughSyncPath(facts.owner.name, facts.lifetime), }); } diff --git a/packages/core-di-engine/src/private/strategy.ts b/packages/core-di-engine/src/private/strategy.ts index f45fb32..f3a0ed4 100644 --- a/packages/core-di-engine/src/private/strategy.ts +++ b/packages/core-di-engine/src/private/strategy.ts @@ -1,6 +1,6 @@ import type { Lifetime } from '../enums'; import type { DescriptorMap, ServiceIdentifier, ServiceRegistration, SourceType } from '../types'; -import type { Boundary } from './boundaryEngine'; +import type { Boundary, SurfaceReach } from './boundaryEngine'; import type { BuildFn, Env, GraphNode } from './types'; /** @@ -32,8 +32,13 @@ export type ResolvedField = { readonly field: string; readonly value: unknown }; export type StrategyKit = { readonly lifetimeOf: (node: GraphNode) => Lifetime; readonly isCached: (lifetime: Lifetime) => boolean; - readonly surfaceAt: (token: ServiceIdentifier) => 'root' | 'boundary' | undefined; - readonly surfaceValue: (at: 'root' | 'boundary', boundary: Boundary) => unknown; + readonly surfaceAt: (token: ServiceIdentifier) => SurfaceReach | undefined; + /** The surface serving this token at this boundary. Throws when the reach excludes the boundary, so a strategy must convert it like any other failure. */ + readonly surfaceValue: (at: SurfaceReach, boundary: Boundary, token: ServiceIdentifier) => unknown; + /** A fresh resolution pass at the root boundary, where a singleton and everything it depends on belongs. */ + readonly rootPass: () => { readonly env: Env; readonly boundary: Boundary }; + /** The root's registrations. A singleton is provider-wide, so a scope's overlay (a `.shadow()`, say) must not reach what it depends on. */ + readonly rootView: () => EngineView; /** The multiplicity guard: an error to raise for this token's bucket, or undefined. */ readonly guardToken: (token: ServiceIdentifier, nodes: readonly GraphNode[]) => unknown | undefined; /** Token to concrete node: applies the guard, picks the last registration, follows forwards. Throws. */ diff --git a/packages/core-di-engine/src/types.ts b/packages/core-di-engine/src/types.ts index 2b1a758..cc8c049 100644 --- a/packages/core-di-engine/src/types.ts +++ b/packages/core-di-engine/src/types.ts @@ -1,4 +1,4 @@ -import type { Lifetime, ValidationProblemKind } from './enums'; +import type { Lifetime, Severity, ValidationProblemKind } from './enums'; import type { IResolutionScope } from './interfaces'; export type SourceType = object; @@ -53,16 +53,19 @@ export type ServiceDescriptor = { export type MetadataType = Record>; -/** A single wiring problem reported by validation. */ +/** A single wiring problem reported by validation. The producing policy stamps the severity, so a problem carried away from its report still knows what it is. */ export type ValidationProblem = { readonly kind: ValidationProblemKind; + readonly severity: Severity; readonly message: string; }; -/** The diagnostic report returned by validation. */ +/** The diagnostic report returned by validation. Errors and warnings are separate so "can I build?" and "what should I look at?" are each one property read. */ export type ValidationReport = { + /** Whether the wiring can be trusted to build: true when there are no errors. Warnings never make a report invalid. */ readonly valid: boolean; - readonly problems: ValidationProblem[]; + readonly errors: readonly ValidationProblem[]; + readonly warnings: readonly ValidationProblem[]; }; declare const asyncBrand: unique symbol; diff --git a/packages/core-di-engine/test/boundaryEngine.spec.ts b/packages/core-di-engine/test/boundaryEngine.spec.ts index 4d7cd54..d467cd6 100644 --- a/packages/core-di-engine/test/boundaryEngine.spec.ts +++ b/packages/core-di-engine/test/boundaryEngine.spec.ts @@ -17,12 +17,14 @@ import { InvalidOperationError, Lifetime, RuntimeCaptivePolicy, + ScopeMismatchError, SelfDependencyError, ServiceCreationError, type ServiceDescriptor, type ServiceIdentifier, type ServiceImplementation, type SourceType, + type SurfaceReach, UnregisteredServiceError, } from '../src'; import { dependsOn } from '../src/dependsOn'; @@ -33,6 +35,13 @@ import { holder } from './strategyHolder'; // The strategy comes from the holder: plan by default, naive under the parity // run (boundaryEngine-naive.spec.ts), which must observe identical behaviour. +// The three reaches, each bound to a token the rest of the suite never touches: `root` +// always answers with the root surface, `nearest` with whichever boundary asked, and +// `scope` only with a real scope. +abstract class IRootReach {} +abstract class INearestReach {} +abstract class IScopeReach {} + const composition = (): EngineComposition => ({ features: { [Lifetime.Singleton]: createSingletonLifetime(), @@ -41,6 +50,11 @@ const composition = (): EngineComposition => ({ }, strategy: holder.factory, runtimeCaptivePolicy: RuntimeCaptivePolicy.None, + surfaceTokens: new Map, SurfaceReach>([ + [IRootReach as ServiceIdentifier, 'root'], + [INearestReach as ServiceIdentifier, 'nearest'], + [IScopeReach as ServiceIdentifier, 'scope'], + ]), }); type DescriptorOptions = { @@ -888,3 +902,149 @@ describe('boundaryEngine: async at the build boundary: buildEngineAsync', () => expect(actual).toThrow(/IAsyncResource/); }); }); + +abstract class IPer {} +class Per implements IPer {} + +abstract class IFirstHolder { + abstract readonly per: IPer; +} +class FirstHolder implements IFirstHolder { + @dependsOn(IPer) public readonly per!: IPer; +} + +abstract class ISecondHolder { + abstract readonly per: IPer; +} +class SecondHolder implements ISecondHolder { + @dependsOn(IPer) public readonly per!: IPer; +} + +abstract class ICaller { + abstract readonly per: IPer; + abstract readonly first: IFirstHolder; + abstract readonly second: ISecondHolder; +} +class Caller implements ICaller { + @dependsOn(IPer) public readonly per!: IPer; + @dependsOn(IFirstHolder) public readonly first!: IFirstHolder; + @dependsOn(ISecondHolder) public readonly second!: ISecondHolder; +} + +// A singleton is constructed once and shared by everything, so it cannot take part in +// the sharing a resolve-lifetime dependency promises: it resolves in a pass of its own. +// What it holds is therefore the same however it came to be built, which is what stops +// call order and prebaking from changing the object graph. +describe('a singleton resolves in a pass of its own', () => { + const holders = (eager: boolean): DescriptorMap => mapOf([IPer, descriptor(Per)], [IFirstHolder, descriptor(FirstHolder, { lifetime: Lifetime.Singleton, eager })], [ISecondHolder, descriptor(SecondHolder, { lifetime: Lifetime.Singleton, eager })], [ICaller, descriptor(Caller, { lifetime: Lifetime.Transient })]); + + it('does not share a resolve-lifetime dependency with another singleton built in the same resolve', () => { + const engine = buildEngine(holders(false), composition()); + + const caller = engine.resolve(ICaller); + const actual = caller.first.per === caller.second.per; + + expect(actual).toBe(false); + }); + + it('does not share a resolve-lifetime dependency with the resolve that triggered its construction', () => { + const engine = buildEngine(holders(false), composition()); + + const caller = engine.resolve(ICaller); + const actual = caller.per === caller.first.per; + + expect(actual).toBe(false); + }); + + it('gives the same answer when the singletons are prebaked instead', () => { + const engine = buildEngine(holders(true), composition()); + + const actual = engine.resolve(IFirstHolder).per === engine.resolve(ISecondHolder).per; + + expect(actual).toBe(false); + }); +}); + +// A surface is bound, not registered, so both doors have to know about it: resolve +// answers with the surface its reach allows and refuses where the reach allows none, +// while resolveAll lists what is there, which is nothing rather than a refusal. +describe('a surface token and how far its reach carries', () => { + const bound = () => { + const engine = buildEngine(mapOf(), composition()); + engine.bindSurface('root-surface'); + const scope = engine.createScope(); + scope.bindSurface('scope-surface'); + return { engine, scope }; + }; + + it('answers a root-reach token with the root surface, from a scope', () => { + const { scope } = bound(); + + const expected = 'root-surface'; + const actual = scope.resolve(IRootReach as ServiceIdentifier); + + expect(actual).toBe(expected); + }); + + it('answers a nearest-reach token with the root surface at the root', () => { + const { engine } = bound(); + + const expected = 'root-surface'; + const actual = engine.resolve(INearestReach as ServiceIdentifier); + + expect(actual).toBe(expected); + }); + + it('answers a nearest-reach token with the scope inside a scope', () => { + const { scope } = bound(); + + const expected = 'scope-surface'; + const actual = scope.resolve(INearestReach as ServiceIdentifier); + + expect(actual).toBe(expected); + }); + + it('answers a scope-reach token with the scope inside a scope', () => { + const { scope } = bound(); + + const expected = 'scope-surface'; + const actual = scope.resolve(IScopeReach as ServiceIdentifier); + + expect(actual).toBe(expected); + }); + + it('refuses a scope-reach token at the root, where there is no scope', () => { + const { engine } = bound(); + + const actual = () => engine.resolve(IScopeReach as ServiceIdentifier); + + expect(actual).toThrow(ScopeMismatchError); + }); + + it('lists the one surface a reach allows', () => { + const { scope } = bound(); + + const expected = ['scope-surface']; + const actual = scope.resolveAll(IScopeReach as ServiceIdentifier); + + expect(actual).toEqual(expected); + }); + + it('lists nothing for a scope-reach token at the root, rather than refusing', () => { + const { engine } = bound(); + + const expected: unknown[] = []; + const actual = engine.resolveAll(IScopeReach as ServiceIdentifier); + + expect(actual).toEqual(expected); + }); + + it('lists nothing when the composition never bound a surface', () => { + const engine = buildEngine(mapOf(), composition()); + + const expected: unknown[] = []; + const actual = engine.resolveAll(IRootReach as ServiceIdentifier); + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/core-di-engine/test/graph.spec.ts b/packages/core-di-engine/test/graph.spec.ts index 30dc2bb..8e8f5a2 100644 --- a/packages/core-di-engine/test/graph.spec.ts +++ b/packages/core-di-engine/test/graph.spec.ts @@ -227,12 +227,15 @@ describe('buildPlan: a flat plan of per-injection steps', () => { return buildPlan(graph, index, root, lifetimeOf, notTransient); }; - const diamond = (sharedLifetime: Lifetime): DescriptorMap => { + // The consumers are resolve-lifetime unless a test says otherwise: a singleton opens + // a pass of its own, which is a separate question from how a shared dependency is + // reached within one pass. + const diamond = (sharedLifetime: Lifetime, consumerLifetime: Lifetime = Lifetime.Resolve): DescriptorMap => { const services = createDescriptorMap(); register(services, ID, { implementation: D, lifetime: sharedLifetime }); - register(services, IB, { implementation: B }); - register(services, IC, { implementation: C }); - register(services, IA, { implementation: A }); + register(services, IB, { implementation: B, lifetime: consumerLifetime }); + register(services, IC, { implementation: C, lifetime: consumerLifetime }); + register(services, IA, { implementation: A, lifetime: consumerLifetime }); return services; }; @@ -254,6 +257,37 @@ describe('buildPlan: a flat plan of per-injection steps', () => { expect(actual).toBe(expected); }); + it('emits a construction step per singleton for a cached dependency two singletons share', () => { + const plan = planFor(diamond(Lifetime.Scoped, Lifetime.Singleton), IA); + + const expected = 2; + const actual = buildSteps(plan).filter((step) => step.token === ID).length; + + expect(actual).toBe(expected); + }); + + it('gives each singleton its own pass, and the caller none', () => { + const plan = planFor(diamond(Lifetime.Scoped, Lifetime.Singleton), IA); + + const expected = 3; + const actual = new Set(buildSteps(plan).map((step) => (step.kind === 'build' ? step.pass : undefined))).size; + + expect(actual).toBe(expected); + }); + + it('emits one construction step for a singleton reached from two places', () => { + const services = createDescriptorMap(); + register(services, ID, { implementation: D, lifetime: Lifetime.Singleton }); + register(services, IB, { implementation: B, lifetime: Lifetime.Transient }); + register(services, IC, { implementation: C, lifetime: Lifetime.Transient }); + register(services, IA, { implementation: A, lifetime: Lifetime.Transient }); + + const expected = 1; + const actual = buildSteps(planFor(services, IA)).filter((step) => step.token === ID).length; + + expect(actual).toBe(expected); + }); + it('collapses a multi-hop forward chain to its terminal node', () => { abstract class ITarget {} abstract class IMid {} diff --git a/packages/core-di-lite/CHANGELOG.md b/packages/core-di-lite/CHANGELOG.md index cf346df..7513d5f 100644 --- a/packages/core-di-lite/CHANGELOG.md +++ b/packages/core-di-lite/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- `validate()` returns `errors` and `warnings` separately, each problem carrying its severity. Lite composes no policy that warns, so every problem it reports is an error. + ### Removed - `.singleton()` is no longer a builder verb: singleton is the only lifetime and it was never configurable, so the verb could never override anything. Every registration is still stamped a singleton at `buildProvider`. diff --git a/packages/core-di-lite/README.md b/packages/core-di-lite/README.md index ccdd98a..a267809 100644 --- a/packages/core-di-lite/README.md +++ b/packages/core-di-lite/README.md @@ -149,10 +149,8 @@ services.forward(ILegacyName).to(IGreeter); ```ts const report = services.validate(); -if (!report.valid) { - for (const problem of report.problems) { - console.warn(problem.kind, problem.message); - } +for (const problem of [...report.errors, ...report.warnings]) { + console.warn(problem.severity, problem.kind, problem.message); } ``` diff --git a/packages/core-di-lite/changes.jsonl b/packages/core-di-lite/changes.jsonl index 99eb783..2050272 100644 --- a/packages/core-di-lite/changes.jsonl +++ b/packages/core-di-lite/changes.jsonl @@ -15,3 +15,4 @@ {"description":"`DuplicateRegistrationError`. Duplicate registration is no longer an error at register time.","category":"removed"} {"type":"release","version":"5.0.0","date":"2026-07-16","tag":"core-di-lite@5.0.0","description":"Rebuilt on `@shellicar/core-di-engine`, the shared engine that core-di also composes from. The version jumps to match core-di and core-di-engine: the three packages now release in lockstep so a single engine copy resolves across them. Lite keeps its purpose: everything is a singleton, everything is constructed at `buildProvider()`, and a resolve after build is a pure lookup."} {"description":"`.singleton()` is no longer a builder verb: singleton is the only lifetime and it was never configurable, so the verb could never override anything. Every registration is still stamped a singleton at `buildProvider`.","category":"removed"} +{"description":"`validate()` returns `errors` and `warnings` separately, each problem carrying its severity. Lite composes no policy that warns, so every problem it reports is an error.","category":"changed"} diff --git a/packages/core-di-lite/src/createServiceCollection.ts b/packages/core-di-lite/src/createServiceCollection.ts index b18b002..b9d1c05 100644 --- a/packages/core-di-lite/src/createServiceCollection.ts +++ b/packages/core-di-lite/src/createServiceCollection.ts @@ -15,6 +15,7 @@ import { pushBucket, RuntimeCaptivePolicy, runGraphPolicies, + Severity, ValidationProblemKind, } from '@shellicar/core-di-engine'; import type { IServiceCollection, IServiceProvider } from './interfaces'; @@ -62,12 +63,14 @@ export const createServiceCollection = (): IServiceCollection => { validate(): ValidationReport { const problems: ValidationProblem[] = composed.unfaced().map((node) => ({ kind: ValidationProblemKind.NoIdentity, + severity: Severity.Error, message: noDeclaredIdentity(node.implementation.name), })); // No captive or async-path policies: lite composes neither scoped // lifetimes nor async factories, so those problems cannot exist here. problems.push(...runGraphPolicies(deriveFacts(composed.regs), [missingTargetPolicy, cyclePolicy])); - return { valid: problems.length === 0, problems }; + // Lite composes no policy that warns, so every problem it can produce is an error. + return { valid: problems.length === 0, errors: problems, warnings: [] }; }, buildProvider(): IServiceProvider { stampSingleton(composed.regs); diff --git a/packages/core-di/CHANGELOG.md b/packages/core-di/CHANGELOG.md index 7c3a77b..4fdbc4e 100644 --- a/packages/core-di/CHANGELOG.md +++ b/packages/core-di/CHANGELOG.md @@ -13,16 +13,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `IScopedProvider.createScope()` opens a nested scope: it starts with the parent scope's registrations at that moment, holds its own scoped instances, and shares singletons with the whole provider. - `createServiceCollection({ eagerSingletons })` constructs every singleton at `buildProvider`, not just the `.eager()` and async ones, and a constructor that throws now throws there too instead of at the first resolve. Defaults to `false` (unchanged behaviour when omitted). - A scope can `.shadow()` a registration on `register()`/`forward()` to override an ancestor scope's registration of the same token, instead of throwing `MultipleRegistrationError`. Only available on `IScopedProvider.Services`; a root collection's `register()`/`forward()` never carry `.shadow()`. +- A dependency on `IScopedProvider` is reported as a scope mismatch: an error for a singleton, which serves the whole provider and so can never be given a scope, and a warning for any other lifetime, which is served correctly inside a scope and only wrong from the root. +- `validate()` warns when a singleton holds a scoped or resolve dependency, which is shared more narrowly than the singleton itself: it takes a private instance where a shared one was asked for. A scoped dependency reports this alongside the captive dependency, which is the separate disposal hazard. ### Changed - `runtimeCaptivePolicy` now defaults to `RuntimeCaptivePolicy.Throw`: a singleton pulling a scoped instance through an opaque factory throws `CaptiveDependencyError` at `resolve()`. Pass `RuntimeCaptivePolicy.None` to allow the capture as before. - A registration's lifetime is fixed once the collection is committed (provider built, or resolved in a scope): a lifetime verb after that point throws, naming the commit. - Resolving the `IScopedProvider` token now throws `UnregisteredServiceError` at the root: the root is not a scope, so it no longer answers for the token, the same way an unregistered service does. Inside a scope, resolving it still returns the scope itself, unchanged. +- `validate()` returns `errors` and `warnings` separately, each problem carrying its severity. Only errors make a report invalid, so `buildProvider({ validate: true })` refuses on an error and builds through a warning. +- Resolving `IScopedProvider` from the root throws `ScopeMismatchError` instead of `UnregisteredServiceError`: the engine binds the token, so nothing is missing, the root simply has no scope to serve. +- The root provider's `resolve` no longer accepts `IScopedProvider`: asking the root for a scope does not typecheck. +- A `ValidationError` from `buildProvider({ validate: true })` carries `errors` and `warnings` rather than a single `problems`, so the warnings from the same run are on it too. ### Fixed - Fixed an `.eager()` singleton (or any singleton under `eagerSingletons`) resolving `IServiceProvider` or `IResolutionScope` during construction: it received `undefined` instead of the provider, because the root surface was bound only after eager construction ran. +- Fixed `validate()` reporting `IServiceProvider`, `IScopedProvider`, and `IResolutionScope` as missing targets: these are bound by the engine at build, never registered, and `resolve()` already handled them correctly. +- Injecting `IScopedProvider` into a service resolved from the root throws `ScopeMismatchError`, instead of silently handing it the root provider wearing the scoped type. +- A singleton no longer captures what the scope that first resolved it owned. Its dependencies are the root's instances, resolved against the root's registrations, so a scope's `.shadow()` cannot reach an instance the whole provider shares. +- A resolve-lifetime dependency of a singleton is no longer shared with whatever else happened to be built alongside it. Each singleton is constructed in its own pass, so what it holds is the same whichever call built it and whether or not `eagerSingletons` is set. +- `validate()` reports a scope mismatch for a service a singleton can reach, not only for the singleton itself: a scoped service reached from a singleton resolves at the root with it, where `IScopedProvider` can never be served. +- `resolveAll(IServiceProvider)` and `resolveAll(IResolutionScope)` return the resolving surface rather than an empty list, and `resolveAll(IScopedProvider)` returns the scope inside a scope and nothing at the root. ## [5.0.0] - 2026-07-16 diff --git a/packages/core-di/README.md b/packages/core-di/README.md index 850380a..63eedc8 100644 --- a/packages/core-di/README.md +++ b/packages/core-di/README.md @@ -264,14 +264,12 @@ scope.resolve(Connection); // the scoped Connection is disposed when the scope is disposed ``` -* Validate the wiring statically. `validate()` reads the dependency graph (unregistered targets, cycles, captive dependencies) with no construction and returns a report without throwing (cheap to run in CI). `buildProvider` stays lenient by default; pass `{ validate: true }` to fail fast with a `ValidationError`. One shape is invisible to it by construction: a factory doing its own inline `scope.resolve(...)` has no static edge to read. A singleton capturing a scoped instance that way is caught at resolve time instead, by `runtimeCaptivePolicy` (default `Throw`). +* Validate the wiring statically. `validate()` reads the dependency graph (unregistered targets, cycles, captive dependencies, scope mismatches) with no construction and returns a report without throwing (cheap to run in CI). The report separates `errors` from `warnings`: errors are wiring that cannot be trusted to build and are what make a report invalid, warnings are hazards worth looking at that never block a build. `buildProvider` stays lenient by default; pass `{ validate: true }` to fail fast with a `ValidationError` carrying the errors. One shape is invisible to it by construction: a factory doing its own inline `scope.resolve(...)` has no static edge to read. A singleton capturing a scoped instance that way is caught at resolve time instead, by `runtimeCaptivePolicy` (default `Throw`). ```ts const report = services.validate(); -if (!report.valid) { - for (const problem of report.problems) { - console.warn(problem.kind, problem.message); - } +for (const problem of [...report.errors, ...report.warnings]) { + console.warn(problem.severity, problem.kind, problem.message); } // Or fail fast at build: diff --git a/packages/core-di/changes.jsonl b/packages/core-di/changes.jsonl index 70319eb..6031ac3 100644 --- a/packages/core-di/changes.jsonl +++ b/packages/core-di/changes.jsonl @@ -93,3 +93,15 @@ {"description":"A scope can `.shadow()` a registration on `register()`/`forward()` to override an ancestor scope's registration of the same token, instead of throwing `MultipleRegistrationError`. Only available on `IScopedProvider.Services`; a root collection's `register()`/`forward()` never carry `.shadow()`.","category":"added"} {"description":"Resolving the `IScopedProvider` token now throws `UnregisteredServiceError` at the root: the root is not a scope, so it no longer answers for the token, the same way an unregistered service does. Inside a scope, resolving it still returns the scope itself, unchanged.","category":"changed"} {"description":"Fixed an `.eager()` singleton (or any singleton under `eagerSingletons`) resolving `IServiceProvider` or `IResolutionScope` during construction: it received `undefined` instead of the provider, because the root surface was bound only after eager construction ran.","category":"fixed"} +{"description":"Fixed `validate()` reporting `IServiceProvider`, `IScopedProvider`, and `IResolutionScope` as missing targets: these are bound by the engine at build, never registered, and `resolve()` already handled them correctly.","category":"fixed"} +{"description":"`validate()` returns `errors` and `warnings` separately, each problem carrying its severity. Only errors make a report invalid, so `buildProvider({ validate: true })` refuses on an error and builds through a warning.","category":"changed"} +{"description":"A dependency on `IScopedProvider` is reported as a scope mismatch: an error for a singleton, which serves the whole provider and so can never be given a scope, and a warning for any other lifetime, which is served correctly inside a scope and only wrong from the root.","category":"added"} +{"description":"Resolving `IScopedProvider` from the root throws `ScopeMismatchError` instead of `UnregisteredServiceError`: the engine binds the token, so nothing is missing, the root simply has no scope to serve.","category":"changed"} +{"description":"The root provider's `resolve` no longer accepts `IScopedProvider`: asking the root for a scope does not typecheck.","category":"changed"} +{"description":"Injecting `IScopedProvider` into a service resolved from the root throws `ScopeMismatchError`, instead of silently handing it the root provider wearing the scoped type.","category":"fixed"} +{"description":"A singleton no longer captures what the scope that first resolved it owned. Its dependencies are the root's instances, resolved against the root's registrations, so a scope's `.shadow()` cannot reach an instance the whole provider shares.","category":"fixed"} +{"description":"A resolve-lifetime dependency of a singleton is no longer shared with whatever else happened to be built alongside it. Each singleton is constructed in its own pass, so what it holds is the same whichever call built it and whether or not `eagerSingletons` is set.","category":"fixed"} +{"description":"`validate()` warns when a singleton holds a scoped or resolve dependency, which is shared more narrowly than the singleton itself: it takes a private instance where a shared one was asked for. A scoped dependency reports this alongside the captive dependency, which is the separate disposal hazard.","category":"added"} +{"description":"`validate()` reports a scope mismatch for a service a singleton can reach, not only for the singleton itself: a scoped service reached from a singleton resolves at the root with it, where `IScopedProvider` can never be served.","category":"fixed"} +{"description":"`resolveAll(IServiceProvider)` and `resolveAll(IResolutionScope)` return the resolving surface rather than an empty list, and `resolveAll(IScopedProvider)` returns the scope inside a scope and nothing at the root.","category":"fixed"} +{"description":"A `ValidationError` from `buildProvider({ validate: true })` carries `errors` and `warnings` rather than a single `problems`, so the warnings from the same run are on it too.","category":"changed"} diff --git a/packages/core-di/src/index.ts b/packages/core-di/src/index.ts index 81332d4..c2cbd76 100644 --- a/packages/core-di/src/index.ts +++ b/packages/core-di/src/index.ts @@ -40,9 +40,11 @@ export { ResolveMultipleMode, RuntimeCaptivePolicy, ScopedSingletonRegistrationError, + ScopeMismatchError, SelfDependencyError, ServiceCreationError, ServiceError, + Severity, UnregisteredServiceError, ValidationError, ValidationProblemKind, diff --git a/packages/core-di/src/interfaces.ts b/packages/core-di/src/interfaces.ts index 5c2c368..6a794a8 100644 --- a/packages/core-di/src/interfaces.ts +++ b/packages/core-di/src/interfaces.ts @@ -15,6 +15,17 @@ export abstract class IServiceModule { public abstract registerServices(services: IServiceCollection): void; } +declare const requiresAScope: unique symbol; +/** + * A marker no real token carries, so asking the root for one fails to compile. The + * diagnostic is the name: TypeScript reports the type it could not satisfy and the + * property missing from it, not any string written inside it. + */ +type RequiresAScope = { readonly [requiresAScope]: true }; + +/** `RequiresAScope` for a token only a scope can serve, and nothing extra for every other token. */ +type ScopeOnly = T extends IScopedProvider ? RequiresAScope : unknown; + /** * A scope's resolution surface. Disposables it resolves are disposed when the * scope is disposed (a singleton survives, disposed with the provider). A sync @@ -40,6 +51,13 @@ export abstract class IScopedProvider extends IResolutionScope implements IDispo */ export abstract class IServiceProvider extends IResolutionScope implements IDisposable, IAsyncDisposable { public abstract readonly Services: IServiceCollection; + /** + * Resolves a single implementation, except {@link IScopedProvider}: the root is not + * a scope, so asking it for one does not typecheck. A scope's own `resolve` is + * unaffected. Resolving it here anyway (past the types, or through injection) throws + * {@link ScopeMismatchError}. + */ + public abstract override resolve(identifier: ServiceIdentifier & ScopeOnly): T; public abstract createScope(): IScopedProvider; /** * Writes a human-readable visualisation of the built dependency graph to diff --git a/packages/core-di/src/private/ServiceCollection.ts b/packages/core-di/src/private/ServiceCollection.ts index 19a7112..11d583f 100644 --- a/packages/core-di/src/private/ServiceCollection.ts +++ b/packages/core-di/src/private/ServiceCollection.ts @@ -21,7 +21,7 @@ import { IResolutionScope, type IScopedForwardBuilder, Lifetime, - missingTargetPolicy, + missingTargetPolicyFor, type Newable, noDeclaredIdentity, overrideLifetimePreBuildOnly, @@ -30,7 +30,11 @@ import { ScopedForwardBuilder, type ServiceDescriptor, type ServiceIdentifier, + Severity, type SourceType, + type SurfaceReach, + scopeMismatchPolicyFor, + sharingMismatchPolicy, ValidationError, type ValidationProblem, ValidationProblemKind, @@ -48,6 +52,16 @@ const composedLifetimes = [Lifetime.Singleton, Lifetime.Scoped, Lifetime.Resolve const activeHook = (instrument: InstrumentationOptions | undefined): InstrumentationHook | undefined => (instrument?.enabled === true ? instrument.onTiming : undefined); +// The tokens the engine binds itself at build (root provider, scope surface, +// resolution surface) rather than through registration: composition() wires them +// in as engine surfaces, and validate() reads the same set to know a dependency +// edge onto one of them is never actually missing. +const surfaceTokens = new Map, SurfaceReach>([ + [IServiceProviderToken as ServiceIdentifier, 'root'], + [IScopedProvider as ServiceIdentifier, 'scope'], + [IResolutionScope as ServiceIdentifier, 'nearest'], +]); + // The root collection: register()/forward() carry no .shadow(), in their types or at // runtime. ScopedServiceCollection (below) is the only source of a shadow-capable // collection, born from cloneShared() when a scope is created. @@ -140,6 +154,7 @@ export class ServiceCollection implements IServiceCollection { for (const node of this.composed.unfaced()) { problems.push({ kind: ValidationProblemKind.NoIdentity, + severity: Severity.Error, message: noDeclaredIdentity(node.implementation.name), }); } @@ -148,8 +163,20 @@ export class ServiceCollection implements IServiceCollection { const stamped = this.clone() as ServiceCollection; stamped.stampLifetimes(); const graph = deriveFacts(stamped.services); - problems.push(...runGraphPolicies(graph, [missingTargetPolicy, cyclePolicy, asyncThroughSyncPathPolicy, captivePolicyFor(this.options.captivePolicy)])); - return { valid: problems.length === 0, problems }; + problems.push( + ...runGraphPolicies(graph, [ + missingTargetPolicyFor(new Set(surfaceTokens.keys())), + // Only a scope can serve IScopedProvider, so the edge onto it is judged by + // the consumer's lifetime rather than swallowed with the other surfaces. + scopeMismatchPolicyFor(IScopedProvider as ServiceIdentifier, Lifetime.Scoped), + sharingMismatchPolicy, + cyclePolicy, + asyncThroughSyncPathPolicy, + captivePolicyFor(this.options.captivePolicy), + ]), + ); + const errors = problems.filter((problem) => problem.severity === Severity.Error); + return { valid: errors.length === 0, errors, warnings: problems.filter((problem) => problem.severity === Severity.Warning) }; } // clone and cloneShared differ only in how a descriptor crosses: clone takes a memoised @@ -204,11 +231,7 @@ export class ServiceCollection implements IServiceCollection { prebakeSingletons: this.options.eagerSingletons, disposal: createDisposal(), runtimeCaptivePolicy: this.options.runtimeCaptivePolicy, - surfaceTokens: new Map, 'root' | 'boundary'>([ - [IServiceProviderToken as ServiceIdentifier, 'root'], - [IScopedProvider as ServiceIdentifier, 'boundary'], - [IResolutionScope as ServiceIdentifier, 'boundary'], - ]), + surfaceTokens, }; } @@ -216,7 +239,7 @@ export class ServiceCollection implements IServiceCollection { if (options?.validate) { const report = this.validate(); if (!report.valid) { - throw new ValidationError(report.problems); + throw new ValidationError(report.errors, report.warnings); } } this.built = true; diff --git a/packages/core-di/src/private/provider.ts b/packages/core-di/src/private/provider.ts index eef86cf..f3e9601 100644 --- a/packages/core-di/src/private/provider.ts +++ b/packages/core-di/src/private/provider.ts @@ -1,5 +1,5 @@ import type { Engine, Scope, ServiceIdentifier, SourceType } from '@shellicar/core-di-engine'; -import { IResolutionScope, UnregisteredServiceError } from '@shellicar/core-di-engine'; +import { IResolutionScope, ScopeMismatchError } from '@shellicar/core-di-engine'; import { IScopedProvider, IServiceProvider } from '../interfaces'; import type { ILogger } from '../logger'; import type { InstrumentationHook } from '../types'; @@ -41,12 +41,13 @@ export class ServiceProvider implemen } } - // Only a scope can honestly serve the IScopedProvider token: injecting it declares a + // Only a scope can honestly serve the IScopedProvider token: asking for it declares a // need for scope semantics, which the root doesn't have. Overridden by - // ScopedServiceProvider to hand back itself; the base throws the same error an - // unregistered service gets, since the token genuinely isn't available there. + // ScopedServiceProvider to hand back itself; the base throws a scope mismatch, not an + // unregistered service: the token is bound by the engine, so nothing is missing — the + // root simply cannot serve it. protected asScopedProvider(): IScopedProvider { - throw new UnregisteredServiceError(IScopedProvider); + throw new ScopeMismatchError(IScopedProvider); } private resolveInternal(identifier: ServiceIdentifier): T { @@ -68,10 +69,9 @@ export class ServiceProvider implemen } } + // No short-circuit on an empty bucket: the engine answers that with an empty list + // itself, and a surface token has no bucket to look in while still being resolvable. public resolveAll(identifier: ServiceIdentifier): T[] { - if (this.Services.get(identifier).length === 0) { - return []; - } return this.scope.resolveAll(identifier); } diff --git a/packages/core-di/test/captivePolicy.spec.ts b/packages/core-di/test/captivePolicy.spec.ts index c4a8737..a563bfa 100644 --- a/packages/core-di/test/captivePolicy.spec.ts +++ b/packages/core-di/test/captivePolicy.spec.ts @@ -18,14 +18,18 @@ class TransientHolder implements ITransientHolder { } describe('CaptivePolicy configuration', () => { + // The sharing mismatch is reported whatever the captive policy says, so this asks + // only about the captive: the policy governs the disposal hazard, nothing else. it('None reports no captive problem for a singleton reaching a scoped dependency', () => { const services = createServiceCollection({ captivePolicy: CaptivePolicy.None }); services.register(ScopedDep).as(IScopedDep).scoped(); services.register(ScopedHolder).as(IScopedHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const expected: ValidationProblemKind[] = []; + const report = services.validate(); + const actual = [...report.errors, ...report.warnings].filter((p) => p.kind === ValidationProblemKind.CaptiveDependency).map((p) => p.kind); - expect(actual).toEqual([]); + expect(actual).toEqual(expected); }); it('Strict reports a captive problem for a singleton reaching a transient dependency', () => { @@ -33,7 +37,7 @@ describe('CaptivePolicy configuration', () => { services.register(TransientDep).as(ITransientDep).transient(); services.register(TransientHolder).as(ITransientHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); }); @@ -43,9 +47,10 @@ describe('CaptivePolicy configuration', () => { services.register(TransientDep).as(ITransientDep).transient(); services.register(TransientHolder).as(ITransientHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); - expect(actual).toEqual([]); + expect(actual).toEqual(expected); }); it('Disposal flags a singleton reaching a scoped dependency, driven through the option', () => { @@ -53,9 +58,9 @@ describe('CaptivePolicy configuration', () => { services.register(ScopedDep).as(IScopedDep).scoped(); services.register(ScopedHolder).as(IScopedHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); }); @@ -175,8 +180,8 @@ describe('the captive detectors partition: declared edges are static-only, facto services.register(ScopedThing).as(IScopedThing).scoped(); services.register(FieldHolder).as(IFieldHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); }); diff --git a/packages/core-di/test/defaultLifetime.spec.ts b/packages/core-di/test/defaultLifetime.spec.ts index cc78b4b..7858f8b 100644 --- a/packages/core-di/test/defaultLifetime.spec.ts +++ b/packages/core-di/test/defaultLifetime.spec.ts @@ -73,9 +73,9 @@ describe('defaultLifetime option: the lifetime an un-verbed registration gets', services.register(ScopedDep).as(IScopedDep).scoped(); services.register(Holder).as(IHolder); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); // Resolving commits the collection: the default lifetime is stamped onto every diff --git a/packages/core-di/test/multi-face-graph.spec.ts b/packages/core-di/test/multi-face-graph.spec.ts index a56abce..089da68 100644 --- a/packages/core-di/test/multi-face-graph.spec.ts +++ b/packages/core-di/test/multi-face-graph.spec.ts @@ -32,7 +32,7 @@ describe('multi-face registrations in the static graph', () => { services.register(Concrete).as(IFace).asSelf(); services.register(Dependent).asSelf().singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual(expected); }); @@ -51,7 +51,7 @@ describe('multi-face registrations in the static graph', () => { services.register(Alpha).as(IAlpha).asSelf(); // IAlpha is the earlier face services.register(Beta).as(IBeta); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual(expected); }); diff --git a/packages/core-di/test/resolve-types.ts b/packages/core-di/test/resolve-types.ts new file mode 100644 index 0000000..7f0f055 --- /dev/null +++ b/packages/core-di/test/resolve-types.ts @@ -0,0 +1,26 @@ +import { createServiceCollection, IResolutionScope, IScopedProvider, IServiceProvider } from '../src'; + +// Type-level only: never executed, and its point is that it type-checks. The root's +// resolve refuses IScopedProvider through a conditional on the token, and that +// conditional separates the two provider types structurally. Anything that made them +// mutually assignable would start refusing IServiceProvider here instead, silently, so +// the tokens that must keep working are asserted alongside the one that must not. +export const rootResolves = () => { + const provider = createServiceCollection().buildProvider(); + + const self: IServiceProvider = provider.resolve(IServiceProvider); + const scope: IResolutionScope = provider.resolve(IResolutionScope); + + // @ts-expect-error - the root is not a scope, so it cannot be asked for one + provider.resolve(IScopedProvider); + + return [self, scope]; +}; + +export const scopeResolves = () => { + const scope = createServiceCollection().buildProvider().createScope(); + + const itself: IScopedProvider = scope.resolve(IScopedProvider); + + return itself; +}; diff --git a/packages/core-di/test/review-fixes.spec.ts b/packages/core-di/test/review-fixes.spec.ts index a5b571d..ca3bcab 100644 --- a/packages/core-di/test/review-fixes.spec.ts +++ b/packages/core-di/test/review-fixes.spec.ts @@ -16,7 +16,7 @@ describe('captive detection judges the effective lifetime of an un-verbed depend services.register(UnverbedDep).as(IUnverbedDep); services.register(Holder).as(IHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); }); @@ -96,7 +96,7 @@ describe('validate() sees a factory node\u2019s @dependsOn field edges', () => { .as(IA); services.register(B).as(IB); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.Cycle]); }); @@ -159,7 +159,7 @@ describe('cycle diagnostics de-duplicate on identity, not name', () => { services.register(second.X).as(second.IX); services.register(second.Y).as(second.IY); - const actual = services.validate().problems.filter((p) => p.kind === ValidationProblemKind.Cycle).length; + const actual = services.validate().errors.filter((p) => p.kind === ValidationProblemKind.Cycle).length; expect(actual).toBe(2); }); @@ -179,10 +179,10 @@ describe('validate() completeness', () => { services.register(Dep).as(IDep).scoped(); services.register(Holder).as(IHolder).singleton(); - const before = services.validate().problems.map((p) => p.kind); + const before = services.validate().warnings.map((p) => p.kind); services.overrideLifetime(IDep, Lifetime.Singleton); - const after = services.validate().valid; + const after = services.validate(); - expect([before, after]).toEqual([[ValidationProblemKind.CaptiveDependency], true]); + expect([before.includes(ValidationProblemKind.CaptiveDependency), after]).toEqual([true, { valid: true, errors: [], warnings: [] }]); }); }); diff --git a/packages/core-di/test/scoped-provider-injection.spec.ts b/packages/core-di/test/scoped-provider-injection.spec.ts new file mode 100644 index 0000000..aa286b1 --- /dev/null +++ b/packages/core-di/test/scoped-provider-injection.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn, IResolutionScope, IScopedProvider, IServiceProvider, ScopeMismatchError } from '../src'; + +class ScopeConsumer { + @dependsOn(IScopedProvider) public readonly scope!: IScopedProvider; +} + +// The correct contract: IScopedProvider names the scope the consumer is resolved +// from. A consumer that outlives that scope, or is resolved where no scope exists, +// has no honest answer for the token. +describe('injecting IScopedProvider into a scoped service', () => { + it('receives the scope it was resolved from', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().scoped(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const expected = scope; + const actual = scope.resolve(ScopeConsumer).scope; + + expect(actual).toBe(expected); + }); + + it('receives its own scope in each scope, not the first one resolved', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().scoped(); + const provider = services.buildProvider(); + provider.createScope().resolve(ScopeConsumer); + const second = provider.createScope(); + + const expected = second; + const actual = second.resolve(ScopeConsumer).scope; + + expect(actual).toBe(expected); + }); +}); + +describe('injecting IScopedProvider into a transient service', () => { + it('receives the scope it was resolved from', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().transient(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const expected = scope; + const actual = scope.resolve(ScopeConsumer).scope; + + expect(actual).toBe(expected); + }); + + it('throws resolving from the root, where there is no scope to receive', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().transient(); + const provider = services.buildProvider(); + + const actual = () => provider.resolve(ScopeConsumer); + + expect(actual).toThrow(ScopeMismatchError); + }); +}); + +describe('injecting IScopedProvider into a resolve-lifetime service', () => { + it('receives the scope it was resolved from', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().resolve(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const expected = scope; + const actual = scope.resolve(ScopeConsumer).scope; + + expect(actual).toBe(expected); + }); +}); + +// resolveAll asks how many there are, so a surface answers with the one instance its +// reach allows and nothing where it allows none. It does not refuse the way resolve +// does: an empty list is what resolveAll says about anything it has nothing for. +describe('resolveAll on a surface token', () => { + it('gives the root provider for IServiceProvider', () => { + const provider = createServiceCollection().buildProvider(); + + const expected = [provider]; + const actual = provider.resolveAll(IServiceProvider); + + expect(actual).toEqual(expected); + }); + + it('gives the resolving surface for IResolutionScope', () => { + const provider = createServiceCollection().buildProvider(); + + const expected = [provider]; + const actual = provider.resolveAll(IResolutionScope); + + expect(actual).toEqual(expected); + }); + + it('gives nothing for IScopedProvider at the root, where there is no scope to list', () => { + const provider = createServiceCollection().buildProvider(); + + const expected: IScopedProvider[] = []; + const actual = provider.resolveAll(IScopedProvider); + + expect(actual).toEqual(expected); + }); + + it('gives the scope itself for IScopedProvider inside a scope', () => { + const scope = createServiceCollection().buildProvider().createScope(); + + const expected = [scope]; + const actual = scope.resolveAll(IScopedProvider); + + expect(actual).toEqual(expected); + }); +}); + +// A singleton is one instance for the whole provider, so whichever scope it captured +// outlives that scope: every later scope keeps seeing the first one. No boundary makes +// this valid, which is why it throws rather than depending on where it was resolved. +describe('injecting IScopedProvider into a singleton', () => { + it('throws resolving from the root', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().singleton(); + const provider = services.buildProvider(); + + const actual = () => provider.resolve(ScopeConsumer); + + expect(actual).toThrow(ScopeMismatchError); + }); + + it('throws resolving from a scope, which it would otherwise capture past that scope', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().singleton(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const actual = () => scope.resolve(ScopeConsumer); + + expect(actual).toThrow(ScopeMismatchError); + }); +}); diff --git a/packages/core-di/test/shadow.spec.ts b/packages/core-di/test/shadow.spec.ts index e449e1b..2c4f6f0 100644 --- a/packages/core-di/test/shadow.spec.ts +++ b/packages/core-di/test/shadow.spec.ts @@ -1,6 +1,6 @@ import { MultipleRegistrationError } from '@shellicar/core-di-engine'; import { describe, expect, it } from 'vitest'; -import { createServiceCollection, IScopedProvider, UnregisteredServiceError } from '../src'; +import { createServiceCollection, IScopedProvider, ScopeMismatchError } from '../src'; abstract class IContext { public abstract readonly user: string; @@ -183,13 +183,16 @@ describe('root forward surface', () => { }); describe('the IScopedProvider token at the root', () => { - it('throws UnregisteredServiceError: the root is not a scope, and anything injecting IScopedProvider is declaring it needs scope semantics', () => { + it('throws ScopeMismatchError: the root is not a scope, and anything asking for IScopedProvider is declaring it needs scope semantics', () => { const services = createServiceCollection(); const provider = services.buildProvider(); + // The root's resolve refuses the token in its own types; this pins what happens to + // a caller who gets past them, from plain JS or through injection. + // @ts-expect-error - the root cannot be asked for a scope const actual = () => provider.resolve(IScopedProvider); - expect(actual).toThrow(UnregisteredServiceError); + expect(actual).toThrow(ScopeMismatchError); }); it('still resolves to the scope itself inside a scope', () => { diff --git a/packages/core-di/test/singleton-resolves-at-root.spec.ts b/packages/core-di/test/singleton-resolves-at-root.spec.ts new file mode 100644 index 0000000..5873438 --- /dev/null +++ b/packages/core-di/test/singleton-resolves-at-root.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn, IResolutionScope } from '../src'; + +abstract class IDep { + abstract readonly origin: string; +} +class RootDep implements IDep { + public readonly origin = 'root'; +} +class ScopeDep implements IDep { + public readonly origin = 'scope'; +} + +abstract class IHolder { + abstract readonly dep: IDep; +} +class Holder implements IHolder { + @dependsOn(IDep) public readonly dep!: IDep; +} + +class ScopeAware { + @dependsOn(IResolutionScope) public readonly scope!: IResolutionScope; +} + +// A singleton is one instance for the whole provider, so it belongs to the root and +// nothing a scope owns may reach it. Which boundary happens to resolve it first is an +// accident of call order, and must not decide what it holds. +describe('a singleton resolved from a scope', () => { + it('holds the root instance of its dependency, not the resolving scope\u2019s', () => { + const services = createServiceCollection(); + services.register(RootDep).as(IDep).scoped(); + services.register(Holder).as(IHolder).singleton(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const expected = provider.resolve(IDep); + const actual = scope.resolve(IHolder).dep; + + expect(actual).toBe(expected); + }); + + it('holds the root provider for IResolutionScope, not the resolving scope', () => { + const services = createServiceCollection(); + services.register(ScopeAware).asSelf().singleton(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + + const expected = provider; + const actual = scope.resolve(ScopeAware).scope; + + expect(actual).toBe(expected); + }); + + it("resolves its dependency from the root's registrations, not a shadow the resolving scope declared", () => { + const services = createServiceCollection(); + services.register(RootDep).as(IDep).scoped(); + services.register(Holder).as(IHolder).singleton(); + const provider = services.buildProvider(); + const scope = provider.createScope(); + scope.Services.register(ScopeDep).as(IDep).shadow().scoped(); + + const expected = 'root'; + const actual = scope.resolve(IHolder).dep.origin; + + expect(actual).toBe(expected); + }); +}); diff --git a/packages/core-di/test/singleton-sharing.spec.ts b/packages/core-di/test/singleton-sharing.spec.ts new file mode 100644 index 0000000..0192a36 --- /dev/null +++ b/packages/core-di/test/singleton-sharing.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn } from '../src'; + +abstract class IPer {} +class Per implements IPer {} + +abstract class IFirst { + abstract readonly per: IPer; +} +class First implements IFirst { + @dependsOn(IPer) public readonly per!: IPer; +} + +abstract class ISecond { + abstract readonly per: IPer; +} +class Second implements ISecond { + @dependsOn(IPer) public readonly per!: IPer; +} + +abstract class ITop { + abstract readonly per: IPer; + abstract readonly first: IFirst; + abstract readonly second: ISecond; +} +class Top implements ITop { + @dependsOn(IPer) public readonly per!: IPer; + @dependsOn(IFirst) public readonly first!: IFirst; + @dependsOn(ISecond) public readonly second!: ISecond; +} + +const collection = (eagerSingletons: boolean) => { + const services = createServiceCollection({ eagerSingletons }); + services.register(Per).as(IPer).resolve(); + services.register(First).as(IFirst).singleton(); + services.register(Second).as(ISecond).singleton(); + services.register(Top).as(ITop).transient(); + return services; +}; + +// A resolve-lifetime instance is shared within one resolve. A singleton is built once +// and reused forever, so it cannot take part in that sharing: it gets an instance of +// its own, for its own construction. What it holds is then the same whoever resolves +// it, whenever, and whether or not singletons are prebaked. +describe('a resolve-lifetime dependency of a singleton', () => { + it('is not shared with another singleton built in the same resolve', () => { + const provider = collection(false).buildProvider(); + + const top = provider.resolve(ITop); + const actual = top.first.per === top.second.per; + + expect(actual).toBe(false); + }); + + it('is not shared with the resolve that triggered the construction', () => { + const provider = collection(false).buildProvider(); + + const top = provider.resolve(ITop); + const actual = top.per === top.first.per; + + expect(actual).toBe(false); + }); + + it('is not shared with another singleton when singletons are prebaked', () => { + const provider = collection(true).buildProvider(); + + const actual = provider.resolve(IFirst).per === provider.resolve(ISecond).per; + + expect(actual).toBe(false); + }); +}); diff --git a/packages/core-di/test/validate-scoped-provider.spec.ts b/packages/core-di/test/validate-scoped-provider.spec.ts new file mode 100644 index 0000000..c0733c6 --- /dev/null +++ b/packages/core-di/test/validate-scoped-provider.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn, IScopedProvider, ValidationProblemKind } from '../src'; + +class ScopeConsumer { + @dependsOn(IScopedProvider) public readonly scope!: IScopedProvider; +} + +// The engine binds IScopedProvider itself, so the edge is never a missing target. +// What validate() has to say instead is whether the consumer can honestly receive +// a scope: a scoped one always can, and a singleton never can, whichever boundary +// constructs it. +describe('validate() on a dependency edge onto IScopedProvider', () => { + it('says nothing about a scoped consumer, which always receives its own scope', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().scoped(); + + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); + + expect(actual).toEqual(expected); + }); + + it('errors on a singleton consumer, which no boundary can ever serve', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().singleton(); + + const expected = [ValidationProblemKind.ScopeMismatch]; + const actual = services.validate().errors.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); + + it('is invalid for a singleton consumer: the registration can never construct', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().singleton(); + + const actual = services.validate().valid; + + expect(actual).toBe(false); + }); + + it('stays valid for a transient consumer, which a scope serves correctly', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().transient(); + + const actual = services.validate().valid; + + expect(actual).toBe(true); + }); + + it('warns about a transient consumer, which receives the root provider when resolved from the root', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().transient(); + + const expected = [ValidationProblemKind.ScopeMismatch]; + const actual = services.validate().warnings.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); + + it('errors on a scoped consumer a singleton can reach, which resolves at the root with it', () => { + abstract class IInner { + abstract readonly scope: IScopedProvider; + } + class Inner implements IInner { + @dependsOn(IScopedProvider) public readonly scope!: IScopedProvider; + } + class Outer { + @dependsOn(IInner) public readonly inner!: IInner; + } + const services = createServiceCollection(); + services.register(Inner).as(IInner).scoped(); + services.register(Outer).asSelf().singleton(); + + const expected = [ValidationProblemKind.ScopeMismatch]; + const actual = services.validate().errors.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); + + it('warns about a resolve-lifetime consumer, which receives the root provider when resolved from the root', () => { + const services = createServiceCollection(); + services.register(ScopeConsumer).asSelf().resolve(); + + const expected = [ValidationProblemKind.ScopeMismatch]; + const actual = services.validate().warnings.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/core-di/test/validate-sharing.spec.ts b/packages/core-di/test/validate-sharing.spec.ts new file mode 100644 index 0000000..a0bd007 --- /dev/null +++ b/packages/core-di/test/validate-sharing.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn, type IServiceCollection, ValidationProblemKind } from '../src'; + +abstract class IDep {} +class Dep implements IDep {} + +abstract class IHolder {} +class Holder implements IHolder { + @dependsOn(IDep) public readonly dep!: IDep; +} + +const singletonHolding = (register: (services: IServiceCollection) => void) => { + const services = createServiceCollection(); + register(services); + services.register(Holder).as(IHolder).singleton(); + return services; +}; + +// A singleton may hold what is shared at least as widely as itself, or what is not +// shared at all. Anything shared more narrowly gets a private instance wearing a +// shared contract, and which instance depends on how the singleton came to be built. +describe('validate() on what a singleton holds', () => { + it('reports a resolve-lifetime dependency, which is shared per resolve', () => { + const services = singletonHolding((s) => s.register(Dep).as(IDep).resolve()); + + const expected = [ValidationProblemKind.SharingMismatch]; + const actual = services.validate().warnings.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); + + it('stays valid: every singleton resolves in its own pass, so nothing misbehaves', () => { + const services = singletonHolding((s) => s.register(Dep).as(IDep).resolve()); + + const actual = services.validate().valid; + + expect(actual).toBe(true); + }); + + it('reports a scoped dependency as both a sharing mismatch and the separate disposal hazard', () => { + const services = singletonHolding((s) => s.register(Dep).as(IDep).scoped()); + + const expected = [ValidationProblemKind.SharingMismatch, ValidationProblemKind.CaptiveDependency]; + const actual = services.validate().warnings.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); + + it('says nothing about a transient dependency, which is shared with nobody', () => { + const services = singletonHolding((s) => s.register(Dep).as(IDep).transient()); + + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); + + expect(actual).toEqual(expected); + }); + + it('says nothing about another singleton, shared as widely as itself', () => { + const services = singletonHolding((s) => s.register(Dep).as(IDep).singleton()); + + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); + + expect(actual).toEqual(expected); + }); + + it('reports a dependency reached through an intermediate, not only a direct one', () => { + abstract class IMiddle {} + class Middle implements IMiddle { + @dependsOn(IDep) public readonly dep!: IDep; + } + const services = createServiceCollection(); + services.register(Dep).as(IDep).resolve(); + services.register(Middle).as(IMiddle).transient(); + const holder = class Outer { + @dependsOn(IMiddle) public readonly middle!: IMiddle; + }; + services.register(holder).asSelf().singleton(); + + const expected = [ValidationProblemKind.SharingMismatch]; + const actual = services.validate().warnings.map((p) => p.kind); + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/core-di/test/validate-surface-tokens.spec.ts b/packages/core-di/test/validate-surface-tokens.spec.ts new file mode 100644 index 0000000..c4792cf --- /dev/null +++ b/packages/core-di/test/validate-surface-tokens.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceCollection, dependsOn, IResolutionScope, IScopedProvider, IServiceProvider, type ValidationProblem } from '../src'; + +abstract class INeedsProvider { + abstract provider: IServiceProvider; +} +class NeedsProvider extends INeedsProvider { + @dependsOn(IServiceProvider) public readonly provider!: IServiceProvider; +} + +abstract class INeedsScopedProvider { + abstract provider: IScopedProvider; +} +class NeedsScopedProvider extends INeedsScopedProvider { + @dependsOn(IScopedProvider) public readonly provider!: IScopedProvider; +} + +abstract class INeedsResolutionScope { + abstract scope: IResolutionScope; +} +class NeedsResolutionScope extends INeedsResolutionScope { + @dependsOn(IResolutionScope) public readonly scope!: IResolutionScope; +} + +describe('validate() and engine-bound surface tokens', () => { + it('does not report IServiceProvider as a missing target: the engine binds it, it is never registered', () => { + const services = createServiceCollection(); + services.register(NeedsProvider).asSelf(); + + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); + + expect(actual).toEqual(expected); + }); + + // The edge onto IScopedProvider is judged by the consumer's lifetime instead, as a + // scope mismatch (validate-scoped-provider.spec.ts); what this pins is only that it + // is never an error about the token not being registered. + it('does not report IScopedProvider as a missing target: the engine binds it, it is never registered', () => { + const services = createServiceCollection(); + services.register(NeedsScopedProvider).asSelf(); + + const expected: ValidationProblem[] = []; + const actual = services.validate().errors; + + expect(actual).toEqual(expected); + }); + + it('does not report IResolutionScope as a missing target: the engine binds it, it is never registered', () => { + const services = createServiceCollection(); + services.register(NeedsResolutionScope).asSelf(); + + const expected = { valid: true, errors: [], warnings: [] }; + const actual = services.validate(); + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/core-di/test/validate.spec.ts b/packages/core-di/test/validate.spec.ts index 1a7f142..e91b999 100644 --- a/packages/core-di/test/validate.spec.ts +++ b/packages/core-di/test/validate.spec.ts @@ -24,7 +24,7 @@ describe('validate() as a diagnostic', () => { const services = createServiceCollection(); services.register(Service); // no as / asSelf / forward - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.NoIdentity]); }); @@ -33,7 +33,7 @@ describe('validate() as a diagnostic', () => { const services = createServiceCollection(); services.forward(IService).to(Dependency); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.MissingTarget]); }); @@ -43,9 +43,9 @@ describe('validate() as a diagnostic', () => { services.register(Dependency).as(IDependency).scoped(); services.register(Service).as(IService).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); it('reports a dependency cycle', () => { @@ -61,7 +61,7 @@ describe('validate() as a diagnostic', () => { services.register(CycleA).as(ICycleA); services.register(CycleB).as(ICycleB); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.Cycle]); }); @@ -81,7 +81,7 @@ describe('validate() as a diagnostic', () => { services.register(Beta).as(IBeta); services.forward(IForwarded).to(IBeta); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.Cycle]); }); @@ -102,9 +102,9 @@ describe('validate() as a diagnostic', () => { services.register(Middle).as(IMiddle).transient(); services.register(Root).as(IRoot).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); it('reports a captive dependency reached through a forward (singleton to scoped via a forward)', () => { @@ -120,9 +120,9 @@ describe('validate() as a diagnostic', () => { services.forward(IAliasToScoped).to(IScopedTarget); services.register(Holder).as(IHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); it('reports a dependency cycle that runs through a declared-deps factory', () => { @@ -142,7 +142,7 @@ describe('validate() as a diagnostic', () => { .as(IA); services.register(B).as(IB); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().errors.map((p) => p.kind); expect(actual).toEqual([ValidationProblemKind.Cycle]); }); @@ -170,9 +170,9 @@ describe('validate() as a diagnostic', () => { .transient(); services.register(Root).as(IRoot).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); it('does not flag a scoped dependency hidden behind an opaque factory (the chain terminates)', () => { @@ -220,9 +220,9 @@ describe('validate() as a diagnostic', () => { .scoped(); services.register(Holder).as(IHolder).singleton(); - const actual = services.validate().problems.map((p) => p.kind); + const actual = services.validate().warnings.map((p) => p.kind); - expect(actual).toEqual([ValidationProblemKind.CaptiveDependency]); + expect(actual).toContain(ValidationProblemKind.CaptiveDependency); }); });