Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
98ab140
validate() no longer flags engine-bound surface tokens as missing tar…
shellicar Jul 27, 2026
abe7d82
Add changelog entries and hoist the surface-token map to a shared con…
shellicar Jul 27, 2026
da60c5c
Report validation problems by severity, and name the scope mismatch b…
shellicar Jul 29, 2026
904a3eb
Render a lifetime as a word wherever a message reads as prose
shellicar Jul 29, 2026
b7516bc
Refuse IScopedProvider at the root in the type system, not just at re…
shellicar Jul 29, 2026
b080b53
Record the severity split and the scope mismatch in the changelogs
shellicar Jul 29, 2026
b7d767d
Resolve a singleton at the root, whichever boundary asked for it
shellicar Jul 29, 2026
16ac53c
Record the surface reach and the singleton root-resolution fix in the…
shellicar Jul 29, 2026
fae980c
Give every singleton its own resolution pass, so no caller decides wh…
shellicar Jul 29, 2026
f2bbaf1
Report a singleton holding something shared more narrowly than itself
shellicar Jul 29, 2026
a4cd5d3
Record the singleton pass isolation and the sharing mismatch in the c…
shellicar Jul 29, 2026
344f63c
Judge a scope mismatch on everything a singleton can reach, not only …
shellicar Jul 29, 2026
329d55c
Answer resolveAll with the surface a token's reach allows
shellicar Jul 29, 2026
a6787b5
Pin the root's resolve types in the type-check, and correct lite's re…
shellicar Jul 29, 2026
35242c9
Record the reachability and resolveAll fixes in the changelogs
shellicar Jul 29, 2026
9c1222d
Tie a pass to the singleton, not to where it was reached from
shellicar Jul 29, 2026
380a242
Carry both lists on ValidationError, as the report does
shellicar Jul 29, 2026
033fafb
Prove each surface reach through both doors and both strategies
shellicar Jul 29, 2026
eafea34
Record the review fixes in the changelogs
shellicar Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions examples/core-di/readme/src/validation.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Expand All @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions packages/core-di-engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,32 @@ 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

- `EngineComposition.defaultLifetime` is removed: the engine holds no default lifetime. The composing package stamps a concrete lifetime on every registration before building, and the engine refuses an un-stamped node. This also closes a captive-detection gap where a root that relied on the default was judged by its raw (undefined) lifetime and escaped the walk.
- `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

Expand Down
15 changes: 15 additions & 0 deletions packages/core-di-engine/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
15 changes: 15 additions & 0 deletions packages/core-di-engine/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
31 changes: 26 additions & 5 deletions packages/core-di-engine/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends object> extends ServiceError {
name = 'ScopeMismatchError';
constructor(identifier: ServiceIdentifier<T>) {
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() {
Expand All @@ -72,15 +85,23 @@ export class InvalidImplementationError<T extends object> 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}`;
}
}

Expand Down
19 changes: 16 additions & 3 deletions packages/core-di-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,14 +20,27 @@ export {
InvalidServiceIdentifierError,
MultipleRegistrationError,
ScopedSingletonRegistrationError,
ScopeMismatchError,
SelfDependencyError,
ServiceCreationError,
ServiceError,
UnregisteredServiceError,
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';
Expand All @@ -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 {
Expand Down
Loading
Loading