From 7fe0f09ace966298343f994648aaeb30c06d9865 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:16 -0300 Subject: [PATCH 01/14] feat(di): add a token-based injector with @Contract tokens Purpose-built container with an Angular-compatible surface (inject, runInInjectionContext, Injector, provide/provideLazy, forwardRef). Lookup is class-object first with a fallback to the decorator-set name, per injector level, so per-call string overrides and duplicated contract copies in the extensions tree resolve to the same provider. Includes a legacy provider kind that constructs Yok-style classes via annotate(), lazy side-effect loaders for path-based registration, transient retention, and reverse-instantiation-order disposal. --- lib/common/di/contract.ts | 70 ++++++ lib/common/di/forward-ref.ts | 25 +++ lib/common/di/index.ts | 23 ++ lib/common/di/inject.ts | 30 +++ lib/common/di/injector.ts | 351 ++++++++++++++++++++++++++++++ lib/common/di/providers.ts | 63 ++++++ test/di.ts | 403 +++++++++++++++++++++++++++++++++++ 7 files changed, 965 insertions(+) create mode 100644 lib/common/di/contract.ts create mode 100644 lib/common/di/forward-ref.ts create mode 100644 lib/common/di/index.ts create mode 100644 lib/common/di/inject.ts create mode 100644 lib/common/di/injector.ts create mode 100644 lib/common/di/providers.ts create mode 100644 test/di.ts diff --git a/lib/common/di/contract.ts b/lib/common/di/contract.ts new file mode 100644 index 0000000000..aadab5c855 --- /dev/null +++ b/lib/common/di/contract.ts @@ -0,0 +1,70 @@ +/** + * The name is stored under a `Symbol.for` key deliberately: extensions install + * into their own node_modules tree, so a duplicated copy of this module (and + * of any contract class) must write and read the same property key. A unique + * `Symbol()` would make duplicate copies mutually invisible and break the + * name-fallback lookup in `Injector.get()`. + */ +export const CONTRACT_NAME = Symbol.for("nativescript:di:contractName"); + +export interface IContractOptions { + /** + * Canonical token name, without the `$` prefix. Must be an explicit string + * literal — never derive it from `class.name`, which changes under + * minification. + */ + name: string; +} + +// Per module instance on purpose: a duplicated CLI copy in an extensions tree +// carries its own registry, so contracts redeclared by another copy never +// false-positive here. +const mintedNames = new Map(); + +/** + * Marks an abstract class as a DI token. The decorated class resolves by + * object identity first and by its name on a miss, so duplicated copies of a + * contract remain interchangeable across node_modules trees. + */ +export function Contract( + options: IContractOptions, +): (target: Function) => void { + const { name } = options; + return (target: Function): void => { + const existing = mintedNames.get(name); + if (existing && existing !== target) { + throw new Error( + `@Contract name '${name}' is already used by '${ + existing.name || "another contract" + }'. Token names must be unique — a duplicate silently aliases two contracts.`, + ); + } + mintedNames.set(name, target); + Object.defineProperty(target, CONTRACT_NAME, { + value: name, + writable: false, + enumerable: false, + configurable: false, + }); + }; +} + +/** + * Reads the decorator-set name. Own-property check only: an implementation + * class extending a contract inherits the property, but must not itself act + * as a token. + */ +export function getContractName(token: any): string | undefined { + if ( + typeof token === "function" && + Object.prototype.hasOwnProperty.call(token, CONTRACT_NAME) + ) { + return (token)[CONTRACT_NAME]; + } + return undefined; +} + +/** Test seam — the duplicate-name registry otherwise persists per process. */ +export function clearMintedContractNames(): void { + mintedNames.clear(); +} diff --git a/lib/common/di/forward-ref.ts b/lib/common/di/forward-ref.ts new file mode 100644 index 0000000000..882559d0d3 --- /dev/null +++ b/lib/common/di/forward-ref.ts @@ -0,0 +1,25 @@ +// `Symbol.for` so a duplicated CLI copy in an extensions tree marks thunks +// with the same key this copy reads — mirrors the CONTRACT_NAME reasoning. +const FORWARD_REF = Symbol.for("nativescript:di:forwardRef"); + +/** + * Defers a token reference until the container reads it — for provider arrays + * evaluated at module load, where a class declared later in the file (TDZ) or + * reached through a circular import is not yet a usable binding. Same + * semantics as Angular's forwardRef; resolved at registration and lookup. + * + * This defers *references*, not construction: it cannot break an + * instantiation cycle between two services. For that, inject the Injector and + * resolve late. + */ +export function forwardRef(fn: () => T): T { + (fn)[FORWARD_REF] = true; + return fn; +} + +export function resolveForwardRef(token: T): T { + if (typeof token === "function" && (token)[FORWARD_REF] === true) { + return (token)(); + } + return token; +} diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts new file mode 100644 index 0000000000..6a87afcae9 --- /dev/null +++ b/lib/common/di/index.ts @@ -0,0 +1,23 @@ +export { Injector } from "./injector"; +export { inject, runInInjectionContext } from "./inject"; +export { forwardRef, resolveForwardRef } from "./forward-ref"; +export { + Contract, + getContractName, + CONTRACT_NAME, + clearMintedContractNames, +} from "./contract"; +export type { IContractOptions } from "./contract"; +export { provide, provideLazy } from "./providers"; +export type { + Provider, + ProviderToken, + Type, + AbstractType, + IClassProvider, + IValueProvider, + IFactoryProvider, + ILazyClassProvider, + ILegacyClassProvider, + ILazyRequireProvider, +} from "./providers"; diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts new file mode 100644 index 0000000000..cd4ef9c008 --- /dev/null +++ b/lib/common/di/inject.ts @@ -0,0 +1,30 @@ +import type { Injector } from "./injector"; +import type { ProviderToken } from "./providers"; + +// Sync-only by design (no AsyncLocalStorage): `current` is restored in a +// finally, so inject() is valid in field initializers, constructor bodies and +// provider factories — and never after an await. Self-inject the Injector for +// later lookups. +let current: Injector | null = null; + +export function inject(token: ProviderToken): T { + if (!current) { + throw new Error( + "inject() can only be called from an injection context — a field " + + "initializer, a constructor, or a provider factory running under " + + "runInInjectionContext(). It is not valid after an await; inject " + + "the Injector itself and use injector.get() for late lookups.", + ); + } + return current.get(token); +} + +export function runInInjectionContext(injector: Injector, fn: () => T): T { + const previous = current; + current = injector; + try { + return fn(); + } finally { + current = previous; + } +} diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts new file mode 100644 index 0000000000..81e7015847 --- /dev/null +++ b/lib/common/di/injector.ts @@ -0,0 +1,351 @@ +import { annotate } from "../helpers"; +import { getContractName } from "./contract"; +import { resolveForwardRef } from "./forward-ref"; +import { runInInjectionContext } from "./inject"; +import type { Provider, ProviderToken, Type } from "./providers"; + +type TokenKey = string | Function; + +type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass"; + +interface IProviderRecord { + displayName: string; + kind?: ProviderKind; + shared: boolean; + useValue?: any; + useClass?: Type; + useFactory?: () => any; + useLazyClass?: () => Type; + useLegacyClass?: Function; + /** + * Deferred side-effect loader (Yok's require path). Consumed on first + * resolution; it is expected to register a real resolver onto this record. + */ + pendingLoader?: () => void; + /** Every produced instance is retained, transients included — dispose() walks them. */ + instances: any[]; + constructing: boolean; +} + +// Shared across the whole injector tree so cycle reports show the full path +// even when resolution hops between parent and child scopes. +const resolutionStack: string[] = []; + +export class Injector { + private providers = new Map(); + private instantiationOrder: any[] = []; + + constructor( + providers: Provider[] = [], + private parent?: Injector, + ) { + this.register({ provide: Injector, useValue: this }); + this.register(providers); + } + + /** + * Resolution is two-step per injector level: the token itself, then its + * decorator-set name — and only on a full local miss does lookup delegate + * to the parent. The per-level order is what lets a child scope's + * string-keyed entry (a per-call override, a hook payload) shadow a parent + * provider for class-token consumers too. + * + * `ctorArguments` is the legacy per-call bag: raw Yok semantics (own-key + * check, no `$` normalization), applied only to the construction the call + * itself triggers — it never propagates to nested resolutions. + */ + // T defaults to any so string tokens (which give inference no source and + // would otherwise land on unknown) stay ergonomic during the migration. + public get( + token: ProviderToken, + ctorArguments?: { [key: string]: any }, + ): T { + token = resolveForwardRef(token); + const found = this.findRecord(token); + if (!found) { + throw new Error("unable to resolve " + displayNameOf(token)); + } + return found.owner.instantiate(found.record, ctorArguments); + } + + public createChild(providers: Provider[] = []): Injector { + return new Injector(providers, this); + } + + /** + * Constructs a class that need not be registered, resolving its annotated + * parameters against this injector (plus the given per-call providers, + * which shadow one level deep only). Products are deliberately NOT + * retained for disposal — Yok never retained by-class resolutions either. + */ + public createInstance( + cls: Type | Function, + providers: Provider[] = [], + ctorArguments?: { [key: string]: any }, + ): T { + const scope = providers.length ? this.createChild(providers) : this; + return scope.constructLegacy(cls, ctorArguments); + } + + /** Merge-mutate: re-registering a key updates the existing record in place. */ + public register(providers: Provider | Provider[]): void { + const list = Array.isArray(providers) ? providers : [providers]; + for (const provider of list) { + const keys = this.keysFor(provider.provide); + let record: IProviderRecord | undefined; + for (const key of keys) { + record = this.providers.get(key); + if (record) { + break; + } + } + if (!record) { + record = { + displayName: displayNameOf(provider.provide), + shared: true, + instances: [], + constructing: false, + }; + } + this.applyProvider(record, provider); + for (const key of keys) { + this.providers.set(key, record); + } + } + } + + /** Own-level string keys, optionally filtered by prefix — feeds command-name enumeration. */ + public getRegisteredNames(prefix: string = ""): string[] { + const names: string[] = []; + for (const key of this.providers.keys()) { + if (typeof key === "string" && key.startsWith(prefix)) { + names.push(key); + } + } + return names; + } + + public has(token: ProviderToken): boolean { + return !!this.findRecord(token); + } + + /** First cached instance for a token, without triggering construction. */ + public peek(token: ProviderToken): any { + const found = this.findRecord(token); + return found ? found.record.instances[0] : undefined; + } + + /** Deletes the local record under every key that aliases it. */ + public remove(token: ProviderToken): void { + const record = this.findRecordLocal(token); + if (!record) { + return; + } + const keys: TokenKey[] = []; + for (const [key, value] of this.providers) { + if (value === record) { + keys.push(key); + } + } + for (const key of keys) { + this.providers.delete(key); + } + } + + /** + * Reverse instantiation order, then registration-provided values. Sync, + * like Yok's — the exit paths that call this do not await it. + */ + public dispose(exclude: any[] = []): void { + const seen = new Set(exclude); + const disposeOne = (instance: any) => { + if (!instance || seen.has(instance) || instance === this) { + return; + } + seen.add(instance); + if (typeof instance.dispose === "function") { + instance.dispose(); + } + }; + + for (let i = this.instantiationOrder.length - 1; i >= 0; i--) { + disposeOne(this.instantiationOrder[i]); + } + for (const record of new Set(this.providers.values())) { + for (const instance of record.instances) { + disposeOne(instance); + } + } + } + + private keysFor(token: ProviderToken): TokenKey[] { + token = resolveForwardRef(token); + if (typeof token === "string") { + return [normalizeName(token)]; + } + const name = getContractName(token); + return name !== undefined ? [token, name] : [token]; + } + + private applyProvider(record: IProviderRecord, provider: Provider): void { + record.shared = provider.shared === undefined ? true : provider.shared; + + if ("useLazyRequire" in provider) { + record.pendingLoader = provider.useLazyRequire; + return; + } + + record.useValue = undefined; + record.useClass = undefined; + record.useFactory = undefined; + record.useLazyClass = undefined; + record.useLegacyClass = undefined; + + if ("useValue" in provider) { + record.kind = "value"; + record.useValue = provider.useValue; + if (record.shared) { + record.instances[0] = provider.useValue; + } else { + record.instances.push(provider.useValue); + } + } else if ("useClass" in provider) { + record.kind = "class"; + record.useClass = provider.useClass; + } else if ("useFactory" in provider) { + record.kind = "factory"; + record.useFactory = provider.useFactory; + } else if ("useLazyClass" in provider) { + record.kind = "lazyClass"; + record.useLazyClass = provider.useLazyClass; + } else if ("useLegacyClass" in provider) { + record.kind = "legacyClass"; + record.useLegacyClass = provider.useLegacyClass; + } + } + + private findRecordLocal(token: ProviderToken): IProviderRecord | undefined { + token = resolveForwardRef(token); + if (typeof token === "string") { + return this.providers.get(normalizeName(token)); + } + const direct = this.providers.get(token); + if (direct) { + return direct; + } + const name = getContractName(token); + return name !== undefined ? this.providers.get(name) : undefined; + } + + private findRecord( + token: ProviderToken, + ): { record: IProviderRecord; owner: Injector } | undefined { + const local = this.findRecordLocal(token); + if (local) { + return { record: local, owner: this }; + } + return this.parent ? this.parent.findRecord(token) : undefined; + } + + private instantiate( + record: IProviderRecord, + ctorArguments?: { [key: string]: any }, + ): any { + if (record.pendingLoader) { + const loader = record.pendingLoader; + // Cleared only after a successful run so a failing loader (missing + // module, broken require) is retried on the next resolution. + loader(); + record.pendingLoader = undefined; + } + + if (record.shared && record.instances.length) { + return record.instances[0]; + } + + if ( + record.kind === undefined || + (record.kind === "value" && !record.shared) + ) { + throw new Error("no resolver registered for " + record.displayName); + } + + if (record.kind === "value") { + return record.instances[0]; + } + + if (record.constructing) { + const cyclePath = resolutionStack.concat(record.displayName).join(" -> "); + throw new Error( + `Cyclic dependency detected on dependency '${record.displayName}'. Resolution path: ${cyclePath}`, + ); + } + + record.constructing = true; + resolutionStack.push(record.displayName); + let instance: any; + try { + instance = this.construct(record, ctorArguments); + } finally { + resolutionStack.pop(); + record.constructing = false; + } + + record.instances.push(instance); + this.instantiationOrder.push(instance); + return instance; + } + + private construct( + record: IProviderRecord, + ctorArguments?: { [key: string]: any }, + ): any { + switch (record.kind) { + case "class": + return runInInjectionContext(this, () => new record.useClass()); + case "factory": + return runInInjectionContext(this, () => record.useFactory()); + case "lazyClass": { + if (!record.useClass) { + record.useClass = record.useLazyClass(); + } + const cls = record.useClass; + return runInInjectionContext(this, () => new cls()); + } + case "legacyClass": + return this.constructLegacy(record.useLegacyClass, ctorArguments); + } + } + + private constructLegacy( + ctor: any, + ctorArguments?: { [key: string]: any }, + ): any { + annotate(ctor); + + const resolvedArgs = ctor.$inject.args.map((paramName: string) => + ctorArguments && + Object.prototype.hasOwnProperty.call(ctorArguments, paramName) + ? ctorArguments[paramName] + : this.get(paramName), + ); + + const name = ctor.$inject.name; + return runInInjectionContext(this, () => + name && name[0] === name[0].toUpperCase() + ? new ctor(...resolvedArgs) + : ctor.apply(null, resolvedArgs), + ); + } +} + +function normalizeName(name: string): string { + return name[0] === "$" ? name.slice(1) : name; +} + +function displayNameOf(token: ProviderToken): string { + if (typeof token === "string") { + return normalizeName(token); + } + return getContractName(token) || token.name || ""; +} diff --git a/lib/common/di/providers.ts b/lib/common/di/providers.ts new file mode 100644 index 0000000000..2395dfcb3d --- /dev/null +++ b/lib/common/di/providers.ts @@ -0,0 +1,63 @@ +export type Type = new (...args: any[]) => T; +export type AbstractType = abstract new (...args: any[]) => T; + +export type ProviderToken = string | Type | AbstractType; + +interface IBaseProvider { + provide: ProviderToken; + /** Defaults to true. `false` constructs a fresh instance per resolution. */ + shared?: boolean; +} + +export interface IClassProvider extends IBaseProvider { + useClass: Type; +} + +export interface IValueProvider extends IBaseProvider { + useValue: T; +} + +export interface IFactoryProvider extends IBaseProvider { + useFactory: () => T; +} + +/** The loader runs on first resolution only — module loading stays deferred. */ +export interface ILazyClassProvider extends IBaseProvider { + useLazyClass: () => Type; +} + +/** + * Yok-style resolver constructed via annotate(): parameters are resolved by + * name, and lowercase/anonymous functions are invoked as factories rather + * than new-ed. + */ +export interface ILegacyClassProvider extends IBaseProvider { + useLegacyClass: Function; +} + +/** + * Deferred side-effect loader (Yok's `require(name, path)`): running it is + * expected to register the real resolver onto this same record. + */ +export interface ILazyRequireProvider extends IBaseProvider { + useLazyRequire: () => void; +} + +export type Provider = + | IClassProvider + | IValueProvider + | IFactoryProvider + | ILazyClassProvider + | ILegacyClassProvider + | ILazyRequireProvider; + +/** Enforces at compile time that the implementation satisfies the token. */ +export const provide = ( + token: AbstractType | string, + impl: Type, +): Provider => ({ provide: token, useClass: impl }); + +export const provideLazy = ( + token: AbstractType | string, + load: () => Type, +): Provider => ({ provide: token, useLazyClass: load }); diff --git a/test/di.ts b/test/di.ts new file mode 100644 index 0000000000..8d45c208b6 --- /dev/null +++ b/test/di.ts @@ -0,0 +1,403 @@ +import { assert } from "chai"; +import { + Injector, + inject, + runInInjectionContext, + Contract, + provide, + forwardRef, +} from "../lib/common/di"; + +@Contract({ name: "diTestGreeter" }) +abstract class Greeter { + abstract greet(): string; +} + +class GreeterImpl extends Greeter { + greet(): string { + return "hello"; + } +} + +@Contract({ name: "diTestDevice" }) +abstract class Device { + abstract id: string; +} + +describe("di: tokens and resolution", () => { + it("resolves one singleton via the class, the name, and the $-prefixed name", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + const byClass = injector.get(Greeter); + assert.instanceOf(byClass, GreeterImpl); + assert.strictEqual(injector.get("diTestGreeter"), byClass); + assert.strictEqual(injector.get("$diTestGreeter"), byClass); + }); + + it("does not treat an implementation class as a token via its inherited contract name", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + assert.throws(() => injector.get(GreeterImpl), /unable to resolve/); + }); + + it("throws on a duplicate contract name at declaration time", () => { + assert.throws(() => { + @Contract({ name: "diTestGreeter" }) + abstract class Duplicate {} + void Duplicate; + }, /already used/); + }); + + it("resolves a duplicated contract copy (same name, different class object) to the same provider", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + const original = injector.get(Greeter); + + // Simulates what a duplicated CLI copy's decorator does: same Symbol.for + // key, its own class object, its own registry. + abstract class DuplicatedCopy {} + Object.defineProperty( + DuplicatedCopy, + Symbol.for("nativescript:di:contractName"), + { value: "diTestGreeter" }, + ); + + assert.strictEqual(injector.get(DuplicatedCopy), original); + }); +}); + +describe("di: lazy providers", () => { + it("does not invoke the loader until first get()", () => { + let loads = 0; + const injector = new Injector([ + { + provide: "lazyThing", + useLazyClass: () => { + loads++; + return GreeterImpl; + }, + }, + ]); + + assert.equal(loads, 0); + const first = injector.get("lazyThing"); + assert.equal(loads, 1); + assert.strictEqual(injector.get("lazyThing"), first); + assert.equal(loads, 1); + }); + + it("consumes a pending side-effect loader once, expecting it to register the resolver", () => { + let loads = 0; + const injector = new Injector(); + injector.register({ + provide: "lazyRequired", + useLazyRequire: () => { + loads++; + injector.register({ + provide: "lazyRequired", + useLegacyClass: class LazyRequiredThing {}, + }); + }, + }); + + const instance = injector.get("lazyRequired"); + assert.isOk(instance); + assert.equal(loads, 1); + assert.strictEqual(injector.get("lazyRequired"), instance); + assert.equal(loads, 1); + }); +}); + +describe("di: per-level lookup order", () => { + it("a child's string-keyed override shadows the parent for class-token consumers", () => { + const root = new Injector([ + { provide: Device, useValue: { id: "shared-singleton" } }, + ]); + const child = root.createChild([ + { provide: "diTestDevice", useValue: { id: "per-call" } }, + ]); + + // The class key misses in the child; the name fallback must hit the + // child's entry BEFORE lookup delegates to the parent — otherwise the + // per-call override is silently skipped. + assert.equal(child.get(Device).id, "per-call"); + assert.equal(child.get("diTestDevice").id, "per-call"); + assert.equal(root.get(Device).id, "shared-singleton"); + }); +}); + +describe("di: child scopes", () => { + it("hydrated children see the payload, fall back for services, and stay isolated", () => { + const loggerValue = { log: true }; + const payloadA = { args: ["a"] }; + const payloadB = { args: ["b"] }; + + const root = new Injector([{ provide: "logger", useValue: loggerValue }]); + const childA = root.createChild([ + { provide: "hookArgs", useValue: payloadA }, + ]); + const childB = root.createChild([ + { provide: "hookArgs", useValue: payloadB }, + ]); + + assert.strictEqual(childA.get("hookArgs"), payloadA); + assert.strictEqual(childA.get("$hookArgs"), payloadA); + assert.strictEqual(childB.get("hookArgs"), payloadB); + assert.strictEqual(childA.get("logger"), loggerValue); + assert.throws(() => root.get("hookArgs"), /unable to resolve/); + }); + + it("inject(Injector) returns the nearest injector", () => { + const root = new Injector(); + const child = root.createChild(); + + runInInjectionContext(child, () => { + assert.strictEqual(inject(Injector), child); + }); + runInInjectionContext(root, () => { + assert.strictEqual(inject(Injector), root); + }); + }); +}); + +describe("di: forwardRef", () => { + it("defers a token reference from provider-literal creation to registration", () => { + let LateToken: any; + // The literal is built while the binding is still unassigned — the thunk + // is only read when the injector processes the provider. + const providers = [ + { provide: forwardRef(() => LateToken), useClass: GreeterImpl }, + ]; + LateToken = Greeter; + + const injector = new Injector(providers); + + const instance = injector.get(Greeter); + assert.instanceOf(instance, GreeterImpl); + assert.strictEqual(injector.get("diTestGreeter"), instance); + }); + + it("resolves forwardRef tokens at lookup and inside inject()", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + const direct = injector.get(Greeter); + + assert.strictEqual(injector.get(forwardRef(() => Greeter)), direct); + runInInjectionContext(injector, () => { + assert.strictEqual(inject(forwardRef(() => Greeter)), direct); + }); + }); +}); + +describe("di: cycles", () => { + it("reports the full resolution path", () => { + class CycleA { + constructor(public $cycleB: any) {} + } + class CycleB { + constructor(public $cycleA: any) {} + } + const injector = new Injector([ + { provide: "cycleA", useLegacyClass: CycleA }, + { provide: "cycleB", useLegacyClass: CycleB }, + ]); + + assert.throws( + () => injector.get("cycleA"), + /Cyclic dependency detected on dependency 'cycleA'.*cycleA -> cycleB -> cycleA/, + ); + }); +}); + +describe("di: transients and disposal", () => { + it("shared:false constructs per resolution, retains every instance, and disposes in reverse order", () => { + const disposed: number[] = []; + let seq = 0; + const injector = new Injector([ + { + provide: "transientThing", + shared: false, + useFactory: () => { + const id = ++seq; + return { + id, + dispose: () => disposed.push(id), + }; + }, + }, + ]); + + const first = injector.get("transientThing"); + const second = injector.get("transientThing"); + assert.notStrictEqual(first, second); + + injector.dispose(); + assert.deepEqual(disposed, [2, 1]); + }); + + it("disposes shared singletons in reverse instantiation order", () => { + const disposed: string[] = []; + const injector = new Injector([ + { + provide: "firstService", + useFactory: () => ({ dispose: () => disposed.push("first") }), + }, + { + provide: "secondService", + useFactory: () => ({ dispose: () => disposed.push("second") }), + }, + ]); + + injector.get("firstService"); + injector.get("secondService"); + injector.dispose(); + + assert.deepEqual(disposed, ["second", "first"]); + }); +}); + +describe("di: createInstance", () => { + class MidDep { + constructor(public $leafDep: any) {} + } + + class EntryConsumer { + constructor( + public $midDep: any, + public $leafDep: any, + ) {} + } + + it("resolves annotated $-params, with per-call providers shadowing one level only", () => { + const root = new Injector([ + { provide: "leafDep", useValue: { tag: "root-leaf" } }, + { provide: "midDep", useLegacyClass: MidDep }, + ]); + + const instance = root.createInstance(EntryConsumer, [ + { provide: "leafDep", useValue: { tag: "override-leaf" } }, + ]); + + assert.equal(instance.$leafDep.tag, "override-leaf"); + // The nested dependency is constructed by its owning injector, so the + // per-call override must not leak into it — Yok's bag never propagated. + assert.equal(instance.$midDep.$leafDep.tag, "root-leaf"); + }); + + it("applies a raw ctorArguments bag with own-key semantics and no $ normalization", () => { + const root = new Injector([ + { provide: "leafDep", useValue: { tag: "root-leaf" } }, + { provide: "midDep", useLegacyClass: MidDep }, + ]); + const fakeMid = { fake: "mid" }; + + const instance = root.createInstance(EntryConsumer, [], { + $midDep: fakeMid, + }); + + assert.strictEqual(instance.$midDep, fakeMid); + assert.equal(instance.$leafDep.tag, "root-leaf"); + }); + + it("invokes lowercase resolvers as factories instead of new-ing them", () => { + const injector = new Injector([ + { + provide: "factoryMade", + useLegacyClass: function makeThing() { + return { viaFactory: true }; + }, + }, + ]); + + assert.isTrue(injector.get("factoryMade").viaFactory); + }); +}); + +describe("di: inject()", () => { + it("works in field initializers during construction", () => { + class UsesInject { + public greeter = inject(Greeter); + public injector = inject(Injector); + } + const injector = new Injector([ + provide(Greeter, GreeterImpl), + { provide: "usesInject", useClass: UsesInject }, + ]); + + const instance = injector.get("usesInject"); + assert.instanceOf(instance.greeter, GreeterImpl); + assert.strictEqual(instance.injector, injector); + }); + + it("throws outside an injection context", () => { + assert.throws(() => inject(Greeter), /injection context/); + }); + + it("restores the previous context, including across nesting", () => { + const outer = new Injector(); + const inner = new Injector(); + + runInInjectionContext(outer, () => { + runInInjectionContext(inner, () => { + assert.strictEqual(inject(Injector), inner); + }); + assert.strictEqual(inject(Injector), outer); + }); + assert.throws(() => inject(Injector), /injection context/); + }); +}); + +describe("di: register semantics", () => { + it("a non-shared object literal has no resolver — Yok quirk preserved", () => { + const injector = new Injector([ + { provide: "literalTransient", useValue: {}, shared: false }, + ]); + + assert.throws( + () => injector.get("literalTransient"), + /no resolver registered/, + ); + }); + + it("re-registering a shared value replaces the cached instance", () => { + const injector = new Injector([{ provide: "config", useValue: { v: 1 } }]); + assert.equal(injector.get("config").v, 1); + + injector.register({ provide: "config", useValue: { v: 2 } }); + assert.equal(injector.get("config").v, 2); + }); + + it("re-registering a resolver keeps an already-cached instance", () => { + const injector = new Injector([ + { provide: "svc", useFactory: () => ({ v: 1 }) }, + ]); + const first = injector.get("svc"); + + injector.register({ provide: "svc", useFactory: () => ({ v: 2 }) }); + assert.strictEqual(injector.get("svc"), first); + }); + + it("a contract registration joins an existing record under the same name", () => { + const injector = new Injector([ + { provide: "diTestGreeter", useValue: { preexisting: true } }, + ]); + const cached = injector.get("diTestGreeter"); + + injector.register(provide(Greeter, GreeterImpl)); + + // Mutate-not-replace: the class key now aliases the same record, whose + // cached instance wins over the newly registered resolver. + assert.strictEqual(injector.get(Greeter), cached); + }); + + it("enumerates registered names by prefix", () => { + const injector = new Injector([ + { provide: "commands.build", useValue: {} }, + { provide: "commands.run", useValue: {} }, + { provide: "unrelated", useValue: {} }, + ]); + + assert.sameMembers(injector.getRegisteredNames("commands."), [ + "commands.build", + "commands.run", + ]); + }); +}); From be97b396d8df8f0897f0c3008ed705f8a829b79d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:17 -0300 Subject: [PATCH 02/14] feat(di): trace usage of legacy APIs slated for deprecation reportDeprecation() dedups per api+detail and logs at trace level by default; NS_DEPRECATIONS=warn|error previews the stricter stages so the same call sites can be escalated over releases. Wired at the external entry points only: param-name hook invocation, require-time extension registration, and dynamicCall help templating. --- lib/common/deprecation.ts | 91 +++++++++++++++++++++++++++ lib/common/services/hooks-service.ts | 7 +++ lib/services/extensibility-service.ts | 6 ++ test/deprecation.ts | 89 ++++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 lib/common/deprecation.ts create mode 100644 test/deprecation.ts diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts new file mode 100644 index 0000000000..b30eb984b6 --- /dev/null +++ b/lib/common/deprecation.ts @@ -0,0 +1,91 @@ +/** + * Central reporting point for invocations of legacy/deprecated CLI APIs. + * + * The severity is a single dial so the same call sites can be staged over + * releases: trace (observe usage) → warn (tell users) → error (removal). + * `NS_DEPRECATIONS=warn|error` previews a stricter stage ahead of the default. + */ + +type DeprecationStage = "trace" | "warn" | "error"; + +const DEFAULT_STAGE: DeprecationStage = "trace"; + +interface IDeprecationLogger { + trace(...args: any[]): void; + warn(...args: any[]): void; +} + +export interface IDeprecationReport { + /** Stable identifier of the deprecated API, e.g. "hooks.param-name-signature". */ + api: string; + /** Distinguishes call sites of one API, e.g. a hook path or extension name. */ + detail?: string; + /** + * Logger to report through. When omitted, the logger is resolved lazily from + * the global injector at call time — never at import time — and the report + * is dropped if no logger is resolvable yet. + */ + logger?: IDeprecationLogger; +} + +const reported = new Set(); + +export function reportDeprecation(report: IDeprecationReport): void { + const stage = getDeprecationStage(); + const message = formatMessage(report); + + if (stage === "error") { + throw new Error(message); + } + + const key = report.detail ? `${report.api}::${report.detail}` : report.api; + if (reported.has(key)) { + return; + } + reported.add(key); + + const logger = report.logger || tryResolveGlobalLogger(); + if (!logger) { + return; + } + + if (stage === "warn") { + logger.warn(message); + } else { + logger.trace(message); + } +} + +/** Test seam: reports are deduplicated once per process otherwise. */ +export function clearReportedDeprecations(): void { + reported.clear(); +} + +function formatMessage(report: IDeprecationReport): string { + const detail = report.detail ? ` (${report.detail})` : ""; + return ( + `Legacy CLI API used: ${report.api}${detail}. ` + + `This API is planned for deprecation in a future release; ` + + `set NS_DEPRECATIONS=warn or NS_DEPRECATIONS=error to preview stricter handling.` + ); +} + +function getDeprecationStage(): DeprecationStage { + const value = (process.env.NS_DEPRECATIONS || "").toLowerCase(); + if (value === "warn" || value === "error" || value === "trace") { + return value; + } + return DEFAULT_STAGE; +} + +function tryResolveGlobalLogger(): IDeprecationLogger | null { + try { + const globalInjector = (global).$injector; + if (!globalInjector) { + return null; + } + return globalInjector.resolve("logger"); + } catch (err) { + return null; + } +} diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index 0b9676258a..0d58823d4b 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -2,6 +2,7 @@ import * as path from "path"; import * as util from "util"; import * as _ from "lodash"; import { annotate, getValueFromNestedObject } from "../helpers"; +import { reportDeprecation } from "../deprecation"; import { AnalyticsEventLabelDelimiter } from "../../constants"; import { IOptions, IPerformanceService } from "../../declarations"; import { @@ -245,6 +246,12 @@ export class HooksService implements IHooksService { projectDataHookArg; } + reportDeprecation({ + api: "hooks.param-name-signature", + detail: hook.fullPath, + logger: this.$logger, + }); + const maybePromise = this.$injector.resolve( hookEntryPoint, hookArguments, diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index eff1fb0f0a..4129c2b9d6 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -3,6 +3,7 @@ import * as _ from "lodash"; import { cache } from "../common/decorators"; import * as constants from "../constants"; import { createRegExp, regExpEscape } from "../common/helpers"; +import { reportDeprecation } from "../common/deprecation"; import { INodePackageManager, INpmsSingleResultData } from "../declarations"; import { IFileSystem, @@ -144,6 +145,11 @@ export class ExtensibilityService implements IExtensibilityService { await this.assertExtensionIsInstalled(extensionName); const pathToExtension = this.getPathToExtension(extensionName); + reportDeprecation({ + api: "extensions.require-time-registration", + detail: extensionName, + logger: this.$logger, + }); this.$requireService.require(pathToExtension); return this.getInstalledExtensionData(extensionName); } catch (error) { diff --git a/test/deprecation.ts b/test/deprecation.ts new file mode 100644 index 0000000000..4d1fd681c3 --- /dev/null +++ b/test/deprecation.ts @@ -0,0 +1,89 @@ +import { assert } from "chai"; +import { Yok } from "../lib/common/yok"; +import { + reportDeprecation, + clearReportedDeprecations, +} from "../lib/common/deprecation"; +import { LoggerStub } from "./stubs"; + +describe("deprecation tracer", () => { + let logger: LoggerStub; + let originalEnv: string | undefined; + + beforeEach(() => { + logger = new LoggerStub(); + clearReportedDeprecations(); + originalEnv = process.env.NS_DEPRECATIONS; + delete process.env.NS_DEPRECATIONS; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.NS_DEPRECATIONS; + } else { + process.env.NS_DEPRECATIONS = originalEnv; + } + }); + + it("logs at trace level by default and reports once per api+detail", () => { + reportDeprecation({ api: "test.api", detail: "site-1", logger }); + reportDeprecation({ api: "test.api", detail: "site-1", logger }); + + const occurrences = logger.traceOutput.split("test.api").length - 1; + assert.equal(occurrences, 1); + assert.equal(logger.warnOutput, ""); + + reportDeprecation({ api: "test.api", detail: "site-2", logger }); + assert.include(logger.traceOutput, "site-2"); + }); + + it("escalates to warn with NS_DEPRECATIONS=warn", () => { + process.env.NS_DEPRECATIONS = "warn"; + + reportDeprecation({ api: "test.warn", logger }); + + assert.include(logger.warnOutput, "test.warn"); + assert.equal(logger.traceOutput, ""); + }); + + it("throws with NS_DEPRECATIONS=error — on every call, not just the first", () => { + process.env.NS_DEPRECATIONS = "error"; + + assert.throws( + () => reportDeprecation({ api: "test.error", logger }), + /test\.error/, + ); + assert.throws( + () => reportDeprecation({ api: "test.error", logger }), + /test\.error/, + ); + }); + + it("falls back to the global injector's logger when none is passed", () => { + const globalAny = global; + const previousInjector = globalAny.$injector; + const freshInjector = new Yok(); + const freshLogger = new LoggerStub(); + freshInjector.register("logger", freshLogger); + globalAny.$injector = freshInjector; + + try { + reportDeprecation({ api: "test.global-logger" }); + assert.include(freshLogger.traceOutput, "test.global-logger"); + } finally { + globalAny.$injector = previousInjector; + } + }); + + it("drops the report silently when no logger is resolvable", () => { + const globalAny = global; + const previousInjector = globalAny.$injector; + globalAny.$injector = undefined; + + try { + assert.doesNotThrow(() => reportDeprecation({ api: "test.no-logger" })); + } finally { + globalAny.$injector = previousInjector; + } + }); +}); From 8666f62a8d670daa7c2bd3fec917342b8bef35e7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:18 -0300 Subject: [PATCH 03/14] fix(hooks): reach the global injector as a last resort in @hook A class migrated off property injection has neither $hooksService nor $injector, so the decorator threw at hook-execution time. The global injector must stay last in the chain - tests stub the instance properties and rely on them winning. --- lib/common/helpers.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 86e0f3f722..299ad373e8 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -558,11 +558,19 @@ export function decorateMethod( }; } +/** + * @deprecated Emits the param-name hook payload contract (keyed off the + * decorated method's parameter names); slated for replacement by a typed + * hook API. + */ export function hook(commandName: string) { function getHooksService(self: any): IHooksService { let hooksService: IHooksService = self.$hooksService; if (!hooksService) { - const injector = self.$injector; + // The global injector must stay the LAST resort: tests stub + // self.$hooksService / self.$injector, and a class migrated off + // property injection has neither — only then may the global be used. + const injector = self.$injector || (global).$injector; if (!injector) { throw Error( "Type with hooks needs to have either $hooksService or $injector injected.", @@ -850,6 +858,12 @@ const FN_NAME_AND_ARGS = const FN_ARG_SPLIT = /,/; const FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; +/** + * @deprecated Discovers dependencies by regex-parsing constructor source text — + * the reason tests must run against tsc output and the CLI can never be + * bundled. Kept only for the legacy provider kind and param-name hook + * injection; never add new callers. + */ export function annotate(fn: any) { let $inject: any, fnText: string, argDecl: string[]; From ef83dde8ae3dd587d775dacf20e8b08114790a59 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:19 -0300 Subject: [PATCH 04/14] refactor(yok): re-point the facade at the token-based injector Yok keeps its entire public surface, subclassability, and the global $injector, while storage and resolution delegate to the new container. Command routing, key commands, and the public-API builder are unchanged. Every legacy member now carries @deprecated JSDoc naming its replacement, mirrored on IInjector. --- lib/common/definitions/yok.d.ts | 103 ++++++++++- lib/common/yok.ts | 312 ++++++++++++++++---------------- 2 files changed, 258 insertions(+), 157 deletions(-) diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index 08c663bec5..c430649075 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -2,55 +2,154 @@ import { IDisposable, IDictionary } from "../declarations"; import { ICommand } from "./commands"; import { IKeyCommand, IValidKeyName } from "./key-commands"; +/** + * The legacy injector facade surface. Every member is individually + * @deprecated in favor of the token-based container in lib/common/di; + * the interface itself survives until the hook/extension deprecation + * completes. + */ interface IInjector extends IDisposable { + /** + * @deprecated Use provideLazy() from lib/common/di — the same deferred + * loading, token-based. + */ require(name: string, file: string): void; + /** + * @deprecated Use provideLazy() from lib/common/di — the same deferred + * loading, token-based. + */ require(names: string[], file: string): void; + /** + * @deprecated Legacy public-API builder. + */ requirePublic(names: string | string[], file: string): void; + /** + * @deprecated Legacy public-API builder. + */ requirePublicClass(names: string | string[], file: string): void; + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ requireCommand(name: string, file: string): void; + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ requireCommand(names: string[], file: string): void; + /** + * @deprecated Replaced together with the command registry. + */ requireKeyCommand(name: IValidKeyName, file: string): void; /** * Resolves an implementation by constructor function. * The injector will create new instances for every call. + * @deprecated Use Injector.createInstance from lib/common/di. */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): any; + /** + * @deprecated Use Injector.createInstance from lib/common/di. + */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): T; /** * Resolves an implementation by name. * The injector will create only one instance per name and return the same instance on subsequent calls. + * @deprecated Use inject(Token) in an injection context, or Injector.get + * from lib/common/di. */ resolve(name: string, ctorArguments?: IDictionary): any; + /** + * @deprecated Use inject(Token) in an injection context, or Injector.get + * from lib/common/di. + */ resolve(name: string, ctorArguments?: IDictionary): T; + /** + * @deprecated Legacy command-registry lookup. + */ resolveCommand(name: string): ICommand; + /** + * @deprecated Legacy command-registry lookup. + */ resolveKeyCommand(key: string): IKeyCommand; + /** + * @deprecated Use provide() / Injector.register from lib/common/di; a + * contract's token name keeps string spellings resolvable. + */ register(name: string, resolver: any, shared?: boolean): void; + /** + * @deprecated Slated for replacement by defineCommand and manifest-declared + * commands. + */ registerCommand(name: string, resolver: any): void; + /** + * @deprecated Slated for replacement by defineCommand and manifest-declared + * commands. + */ registerCommand(names: string[], resolver: any): void; + /** + * @deprecated Replaced together with the command registry. + */ registerKeyCommand(key: IValidKeyName, resolver: any): void; + /** + * @deprecated Legacy command-registry enumeration; feeds shell + * autocompletion and help. + */ getRegisteredCommandsNames(includeDev: boolean): string[]; + /** + * @deprecated Legacy command-registry enumeration. + */ getRegisteredKeyCommandsNames(): string[]; + /** + * @deprecated String-reflective help templating; removable only together + * with the help-template pipeline. + */ dynamicCallRegex: RegExp; + /** + * @deprecated See dynamicCallRegex. + */ dynamicCall(call: string, args?: any[]): Promise; + /** + * @deprecated Legacy command-registry routing. + */ isDefaultCommand(commandName: string): boolean; + /** + * @deprecated Legacy command-registry routing. + * Side-effecting: fails with help output on a bad subcommand. + */ isValidHierarchicalCommand( commandName: string, - commandArguments: string[] + commandArguments: string[], ): Promise; + /** + * @deprecated Legacy command-registry routing. + */ getChildrenCommandsNames(commandName: string): string[]; + /** + * @deprecated Hierarchical-routing internals of the legacy command + * registry. + */ buildHierarchicalCommand( parentCommandName: string, - commandLineArguments: string[] + commandLineArguments: string[], ): any; + /** + * @deprecated Backing store of the require('nativescript') surface. + * Do not add new entries through it. + */ publicApi: any; /** * Defines if it's allowed to override already required module. * This can be used in order to allow redefinition of modules, for example $logger can be replaced by a plugin. * Default value is false. + * @deprecated Escape hatch of the legacy require-time module map. */ overrideAlreadyRequiredModule: boolean; } +/** + * @deprecated The process-wide legacy injector global. New code receives the + * container via inject(Injector) from lib/common/di. + */ declare var $injector: IInjector; diff --git a/lib/common/yok.ts b/lib/common/yok.ts index cf06c51445..9bc3a7bab5 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -1,34 +1,22 @@ import * as path from "path"; import * as _ from "lodash"; -import { annotate, isPromise } from "./helpers"; +import { isPromise } from "./helpers"; +import { reportDeprecation } from "./deprecation"; import { ERROR_NO_VALID_SUBCOMMAND_FORMAT } from "./constants"; import { CommandsDelimiters } from "./constants"; import { IDictionary } from "./declarations"; import { IInjector } from "./definitions/yok"; import { ICommandArgument, ICommand } from "./definitions/commands"; import { IKeyCommand, IValidKeyName } from "./definitions/key-commands"; +import { Injector } from "./di/injector"; +/** + * The legacy global facade binding. New code should obtain the container via + * inject(Injector) inside an injection context rather than importing this; + * every legacy member on it is individually marked @deprecated. + */ export let injector: IInjector; -let indent = ""; -function trace(formatStr: string, ...args: any[]) { - // uncomment following lines when debugging dependency injection - // const items: any[] = []; - // for (let _i = 1; _i < arguments.length; _i++) { - // items[_i - 1] = arguments[_i]; - // } - // const util = require("util"); - // console.log(util.format.apply(util, [indent + formatStr].concat(args))); -} - -function pushIndent() { - indent += " "; -} - -function popIndent() { - indent = indent.slice(0, -2); -} - function forEachName(names: any, action: (name: string) => void): void { if (_.isString(names)) { action(names); @@ -37,6 +25,9 @@ function forEachName(names: any, action: (name: string) => void): void { } } +/** + * @deprecated Yok-era class decorator with zero call sites; do not adopt. + */ export function register(...rest: any[]) { return function (target: any): void { // TODO: Check if 'rest' has more arguments that have to be registered @@ -44,6 +35,10 @@ export function register(...rest: any[]) { }; } +/** + * @deprecated Shape of Yok's old internal records; the container now keeps + * provider records in lib/common/di. + */ export interface IDependency { require?: string; resolver?: () => any; @@ -51,22 +46,39 @@ export interface IDependency { shared?: boolean; } +/** + * The Yok facade: the externally reachable injector surface (every IInjector + * member, the global $injector, subclassability) kept intact while storage and + * resolution live in the token-based `Injector` (lib/common/di). Command + * routing, the key-command namespace, and the public-API builder still live + * here — they are separate subsystems that only share the container. + */ export class Yok implements IInjector { + /** + * @deprecated Escape hatch of the legacy require-time module map. + */ public overrideAlreadyRequiredModule: boolean = false; + /** The token-based container backing this facade. */ + private container = new Injector(); + constructor() { this.register("injector", this); } private COMMANDS_NAMESPACE: string = "commands"; private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; - private modules: { - [name: string]: IDependency; - } = {}; - - private resolutionProgress: any = {}; private hierarchicalCommands: IDictionary = {}; + /** New-style access to the backing container, for token-based registration. */ + public get di(): Injector { + return this.container; + } + + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ public requireCommand(names: any, file: string): void { forEachName(names, (commandName) => { const commands = commandName.split( @@ -76,7 +88,7 @@ export class Yok implements IInjector { if (commands.length > 1) { if ( _.startsWith(commands[1], "*") && - this.modules[this.createCommandName(commands[0])] + this.container.has(this.createCommandName(commands[0])) ) { throw new Error( "Default commands should be required before child commands", @@ -96,7 +108,7 @@ export class Yok implements IInjector { if ( commands.length > 1 && - !this.modules[this.createCommandName(commands[0])] + !this.container.has(this.createCommandName(commands[0])) ) { this.require(this.createCommandName(commands[0]), file); if (commands[1] && !commandName.match(/\|\*/)) { @@ -108,18 +120,33 @@ export class Yok implements IInjector { }); } + /** + * @deprecated Use provideLazy() from lib/common/di (via `Yok.di`) — the same + * deferred loading, token-based. + */ public require(names: any, file: string): void { forEachName(names, (name) => this.requireOne(name, file)); } + /** + * @deprecated Key-command counterpart of requireCommand; replaced together + * with the command registry. + */ public requireKeyCommand(name: any, file: string): void { this.requireOne(this.createKeyCommandName(name), file); } + /** + * @deprecated Backing store of the require('nativescript') surface. + * Do not add new entries through it. + */ public publicApi: any = { __modules__: {}, }; + /** + * @deprecated Legacy public-API builder. + */ public requirePublic(names: any, file: string): void { forEachName(names, (name) => { this.requireOne(name, file); @@ -127,6 +154,9 @@ export class Yok implements IInjector { }); } + /** + * @deprecated Legacy public-API builder. + */ public requirePublicClass(names: any, file: string): void { forEachName(names, (name) => { this.requireOne(name, file); @@ -152,7 +182,7 @@ export class Yok implements IInjector { } private resolveInstance(name: string): any { - let classInstance = _.first(this.modules[name].instances); + let classInstance = this.container.peek(name); if (!classInstance) { classInstance = this.resolve(name); } @@ -162,22 +192,29 @@ export class Yok implements IInjector { private requireOne(name: string, file: string): void { const relativePath = path.join("../", file); - const dependency: IDependency = { - require: require("fs").existsSync( - path.join(__dirname, relativePath + ".js"), - ) - ? relativePath - : file, - shared: true, - }; - - if (!this.modules[name] || this.overrideAlreadyRequiredModule) { - this.modules[name] = dependency; + const dependencyPath = require("fs").existsSync( + path.join(__dirname, relativePath + ".js"), + ) + ? relativePath + : file; + + if (!this.container.has(name) || this.overrideAlreadyRequiredModule) { + // Yok replaced the whole record on an allowed re-require, dropping any + // resolver and cached instances with it — preserved via remove(). + this.container.remove(name); + this.container.register({ + provide: name, + useLazyRequire: () => require(dependencyPath), + }); } else { throw new Error(`module '${name}' require'd twice.`); } } + /** + * @deprecated Slated for replacement by defineCommand and manifest-declared + * commands. + */ public registerCommand(names: any, resolver: any): void { forEachName(names, (name) => { const commands = name.split(CommandsDelimiters.HierarchicalCommand); @@ -189,6 +226,9 @@ export class Yok implements IInjector { }); } + /** + * @deprecated Replaced together with the command registry. + */ public registerKeyCommand(name: IValidKeyName, resolver: IKeyCommand): void { this.register(this.createKeyCommandName(name), resolver); } @@ -204,6 +244,10 @@ export class Yok implements IInjector { return defaultCommand; } + /** + * @deprecated Hierarchical-routing internals of the legacy command registry; + * they move out of the container with the defineCommand work. + */ public buildHierarchicalCommand( parentCommandName: string, commandLineArguments: string[], @@ -243,7 +287,7 @@ export class Yok implements IInjector { .split(CommandsDelimiters.HierarchicalCommand) .map((command) => _.startsWith(command, CommandsDelimiters.DefaultCommandSymbol) - ? command.substr(1) + ? command.slice(1) : command, ), ); @@ -332,6 +376,10 @@ export class Yok implements IInjector { ); } + /** + * @deprecated Legacy command-registry routing. + * Side-effecting: fails with help output on a bad subcommand. + */ public async isValidHierarchicalCommand( commandName: string, commandArguments: string[], @@ -358,6 +406,9 @@ export class Yok implements IInjector { return false; } + /** + * @deprecated Legacy command-registry routing. + */ public isDefaultCommand(commandName: string): boolean { return ( commandName.indexOf(CommandsDelimiters.DefaultCommandSymbol) > 0 && @@ -365,31 +416,33 @@ export class Yok implements IInjector { ); } + /** + * @deprecated Use provide() / Injector.register from lib/common/di (via + * `Yok.di`); a contract's token name keeps string spellings resolvable. + */ public register(name: string, resolver: any, shared?: boolean): void { shared = shared === undefined ? true : shared; - trace("registered '%s'", name); - - const dependency: any = this.modules[name] || {}; - dependency.shared = shared; if (_.isFunction(resolver)) { - dependency.resolver = resolver; + // Classes and factory functions alike: the legacy provider kind + // annotate()s the resolver and calls or news it by casing. + this.container.register({ + provide: name, + useLegacyClass: resolver, + shared, + }); } else { - dependency.instances = dependency.instances || []; - if (shared) { - dependency.instances[0] = resolver; - } else { - dependency.instances.push(resolver); - } + this.container.register({ provide: name, useValue: resolver, shared }); } - - this.modules[name] = dependency; } + /** + * @deprecated Legacy command-registry lookup. + */ public resolveCommand(name: string): ICommand { let command: ICommand; const commandModuleName = this.createCommandName(name); - if (!this.modules[commandModuleName]) { + if (!this.container.has(commandModuleName)) { return null; } command = this.resolve(commandModuleName); @@ -397,10 +450,13 @@ export class Yok implements IInjector { return command; } + /** + * @deprecated Legacy command-registry lookup. + */ public resolveKeyCommand(name: string): IKeyCommand { let command: IKeyCommand; const commandModuleName = this.createKeyCommandName(name); - if (!this.modules[commandModuleName]) { + if (!this.container.has(commandModuleName)) { return null; } @@ -409,12 +465,17 @@ export class Yok implements IInjector { return command; } + /** + * @deprecated Use inject(Token) in an injection context, or Injector.get / + * createInstance from lib/common/di (via `Yok.di`). + */ public resolve(param: any, ctorArguments?: IDictionary): any { if (_.isFunction(param)) { - return this.resolveConstructor(param, ctorArguments); - } else { - return this.resolveByName(param, ctorArguments); + // By-class resolution is transient and never retained — Yok did not + // track these instances for disposal either. + return this.container.createInstance(param, [], ctorArguments); } + return this.container.get(param, ctorArguments); } /* Regex to match dynamic calls in the following format: @@ -423,11 +484,20 @@ export class Yok implements IInjector { #{moduleName.functionName(param1, param2)} - multiple parameters separated with comma are supported Check dynamicCall method for sample usage of this regular expression and see how to determine the passed parameters */ + /** + * @deprecated String-reflective help templating; removable only together with + * the help-template pipeline. Usage is + * runtime-traced via reportDeprecation. + */ public get dynamicCallRegex(): RegExp { return /#{([^.]+)\.([^}]+?)(\((.+)\))*}/; } + /** + * @deprecated See dynamicCallRegex. + */ public getDynamicCallData(call: string, args?: any[]): any { + reportDeprecation({ api: "injector.dynamicCall", detail: call }); const parsed = call.match(this.dynamicCallRegex); const module = this.resolve(parsed[1]); if (!args && parsed[3]) { @@ -437,6 +507,9 @@ export class Yok implements IInjector { return module[parsed[2]].apply(module, args); } + /** + * @deprecated See dynamicCallRegex. + */ public async dynamicCall(call: string, args?: any[]): Promise { const data = this.getDynamicCallData(call, args); @@ -447,93 +520,16 @@ export class Yok implements IInjector { return data; } - private resolveConstructor( - ctor: any, - ctorArguments?: { [key: string]: any }, - ): any { - annotate(ctor); - - const resolvedArgs = ctor.$inject.args.map((paramName: any) => { - if (ctorArguments && ctorArguments.hasOwnProperty(paramName)) { - return ctorArguments[paramName]; - } else { - return this.resolve(paramName); - } - }); - - const name = ctor.$inject.name; - if (name && name[0] === name[0].toUpperCase()) { - return new (ctor)(...resolvedArgs); - } else { - return ctor.apply(null, resolvedArgs); - } - } - - private resolveByName(name: string, ctorArguments?: IDictionary): any { - if (name[0] === "$") { - name = name.substr(1); - } - - if (this.resolutionProgress[name]) { - throw new Error(`Cyclic dependency detected on dependency '${name}'`); - } - this.resolutionProgress[name] = true; - - trace("resolving '%s'", name); - pushIndent(); - - let dependency: IDependency; - let instance: any; - try { - dependency = this.resolveDependency(name); - - if (!dependency) { - throw new Error("unable to resolve " + name); - } - - if ( - !dependency.instances || - !dependency.instances.length || - !dependency.shared - ) { - if (!dependency.resolver) { - throw new Error("no resolver registered for " + name); - } - - dependency.instances = dependency.instances || []; - - instance = this.resolveConstructor(dependency.resolver, ctorArguments); - dependency.instances.push(instance); - } else { - instance = _.first(dependency.instances); - } - } finally { - popIndent(); - delete this.resolutionProgress[name]; - } - - return instance; - } - - private resolveDependency(name: string): IDependency { - const module = this.modules[name]; - if (!module) { - throw new Error("unable to resolve " + name); - } - - if (module.require) { - require(module.require); - } - return module; - } - + /** + * @deprecated Legacy command-registry enumeration; feeds shell autocompletion + * and help, so the `|` encoding is user-visible. + */ public getRegisteredCommandsNames(includeDev: boolean): string[] { - const modulesNames: string[] = _.keys(this.modules); - const commandsNames: string[] = _.filter(modulesNames, (moduleName) => - _.startsWith(moduleName, `${this.COMMANDS_NAMESPACE}.`), + const commandsNames = this.container.getRegisteredNames( + `${this.COMMANDS_NAMESPACE}.`, ); let commands = _.map(commandsNames, (commandName: string) => - commandName.substr(this.COMMANDS_NAMESPACE.length + 1), + commandName.slice(this.COMMANDS_NAMESPACE.length + 1), ); if (!includeDev) { commands = _.reject(commands, (command) => _.startsWith(command, "dev-")); @@ -541,17 +537,22 @@ export class Yok implements IInjector { return commands; } + /** + * @deprecated Legacy command-registry enumeration. + */ public getRegisteredKeyCommandsNames(): string[] { - const modulesNames: string[] = _.keys(this.modules); - const commandsNames: string[] = _.filter(modulesNames, (moduleName) => - _.startsWith(moduleName, `${this.KEY_COMMANDS_NAMESPACE}.`), + const commandsNames = this.container.getRegisteredNames( + `${this.KEY_COMMANDS_NAMESPACE}.`, ); - let commands = _.map(commandsNames, (commandName: string) => - commandName.substr(this.KEY_COMMANDS_NAMESPACE.length + 1), + const commands = _.map(commandsNames, (commandName: string) => + commandName.slice(this.KEY_COMMANDS_NAMESPACE.length + 1), ); return commands; } + /** + * @deprecated Legacy command-registry routing. + */ public getChildrenCommandsNames(commandName: string): string[] { return this.hierarchicalCommands[commandName]; } @@ -564,15 +565,12 @@ export class Yok implements IInjector { return `${this.KEY_COMMANDS_NAMESPACE}.${name}`; } + /** + * @deprecated Delegates to Injector.dispose (reverse instantiation order); + * new code disposes the di container directly. + */ public dispose(): void { - Object.keys(this.modules).forEach((moduleName) => { - const instances = this.modules[moduleName].instances; - _.forEach(instances, (instance) => { - if (instance && instance.dispose && instance !== this) { - instance.dispose(); - } - }); - }); + this.container.dispose([this]); } } @@ -581,6 +579,10 @@ if (!(global).$injector) { injector = (global).$injector; } +/** + * @deprecated Global-singleton wiring for the legacy facade; new code receives + * the container via inject(Injector) instead of a process-wide global. + */ export function setGlobalInjector(inj: IInjector): IInjector { (global).$injector = injector = inj; return inj; From f64ed6a5aa151002daf389e39f54dab633ae4ae5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:34 -0300 Subject: [PATCH 05/14] feat(contracts): ship nativescript/contracts with a first tranche DoctorService and ProjectNameService become @Contract abstract classes; their impls are renamed *Impl (externally invisible - outside resolution is by string name or token, never class identity). The subpath resolves through contracts/package.json rather than an exports map, so existing deep requires keep working, and the entry point is side-effect-free so a duplicated CLI copy in an extensions tree never boots a second runtime. --- contracts/package.json | 4 + lib/contracts/doctor-service.ts | 38 ++++++++++ lib/contracts/index.ts | 26 +++++++ lib/contracts/project-name-service.ts | 13 ++++ lib/services/doctor-service.ts | 86 ++++++++++----------- lib/services/project-name-service.ts | 29 ++++---- scripts/copy-assets.js | 21 ++++-- test/contracts.ts | 31 ++++++++ test/project-name-service.ts | 2 +- test/services/doctor-service.ts | 103 ++++++++++++-------------- 10 files changed, 237 insertions(+), 116 deletions(-) create mode 100644 contracts/package.json create mode 100644 lib/contracts/doctor-service.ts create mode 100644 lib/contracts/index.ts create mode 100644 lib/contracts/project-name-service.ts create mode 100644 test/contracts.ts diff --git a/contracts/package.json b/contracts/package.json new file mode 100644 index 0000000000..4c4d479642 --- /dev/null +++ b/contracts/package.json @@ -0,0 +1,4 @@ +{ + "main": "../lib/contracts/index.js", + "types": "../lib/contracts/index.d.ts" +} diff --git a/lib/contracts/doctor-service.ts b/lib/contracts/doctor-service.ts new file mode 100644 index 0000000000..9a64fbd7a4 --- /dev/null +++ b/lib/contracts/doctor-service.ts @@ -0,0 +1,38 @@ +import { Contract } from "../common/di/contract"; +import type { ISpawnResult } from "../common/declarations"; +import type { IOptions } from "../declarations"; + +/** + * Verifies the host OS configuration — the code behind `ns doctor`. + */ +@Contract({ name: "doctorService" }) +export abstract class DoctorService { + /** + * Verifies the host OS configuration and prints warnings to the users. + * @param configOptions Defines if the result should be tracked by Analytics. + */ + abstract printWarnings(configOptions?: { + trackResult?: boolean; + projectDir?: string; + runtimeVersion?: string; + options?: IOptions; + forceCheck?: boolean; + platform?: string; + }): Promise; + + /** Runs the setup script on the host machine. */ + abstract runSetupScript(): Promise; + + /** + * Checks whether the environment is properly configured for local builds. + */ + abstract canExecuteLocalBuild(configuration?: { + platform?: string; + projectDir?: string; + runtimeVersion?: string; + forceCheck?: boolean; + }): Promise; + + /** Checks and notifies users of deprecated short imports in their app. */ + abstract checkForDeprecatedShortImportsInAppDir(projectDir: string): void; +} diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts new file mode 100644 index 0000000000..b7078cfc08 --- /dev/null +++ b/lib/contracts/index.ts @@ -0,0 +1,26 @@ +// The `nativescript/contracts` entry point (resolved via contracts/package.json +// — deliberately no `exports` map, so existing deep requires keep working). +// +// This module must stay side-effect-free: an extension's duplicated CLI copy +// may load it, and it must never boot a second runtime. In particular nothing +// here may import lib/common/yok (whose import creates global.$injector). + +export { + Contract, + getContractName, + CONTRACT_NAME, +} from "../common/di/contract"; +export type { IContractOptions } from "../common/di/contract"; +export { inject, runInInjectionContext } from "../common/di/inject"; +export { forwardRef, resolveForwardRef } from "../common/di/forward-ref"; +export { Injector } from "../common/di/injector"; +export { provide, provideLazy } from "../common/di/providers"; +export type { + Provider, + ProviderToken, + Type, + AbstractType, +} from "../common/di/providers"; + +export { DoctorService } from "./doctor-service"; +export { ProjectNameService } from "./project-name-service"; diff --git a/lib/contracts/project-name-service.ts b/lib/contracts/project-name-service.ts new file mode 100644 index 0000000000..84d4f9f9f9 --- /dev/null +++ b/lib/contracts/project-name-service.ts @@ -0,0 +1,13 @@ +import { Contract } from "../common/di/contract"; + +@Contract({ name: "projectNameService" }) +export abstract class ProjectNameService { + /** + * Ensures the passed project name is valid; prompts for action otherwise. + * @returns The selected name of the project. + */ + abstract ensureValidName( + projectName: string, + validateOptions?: { force: boolean }, + ): Promise; +} diff --git a/lib/services/doctor-service.ts b/lib/services/doctor-service.ts index 8196762464..cdac1e0947 100644 --- a/lib/services/doctor-service.ts +++ b/lib/services/doctor-service.ts @@ -24,16 +24,19 @@ import { import { IJsonFileSettingsService } from "../common/definitions/json-file-settings-service"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; +import { DoctorService as DoctorServiceContract } from "../contracts/doctor-service"; import { color } from "../color"; import { ITerminalSpinnerService } from "../definitions/terminal-spinner-service"; -export class DoctorService implements IDoctorService { +export class DoctorServiceImpl + implements IDoctorService, DoctorServiceContract +{ private static DarwinSetupScriptLocation = path.join( __dirname, "..", "..", "setup", - "mac-startup-shell-script.sh" + "mac-startup-shell-script.sh", ); private static WindowsSetupScriptExecutable = "powershell.exe"; private static WindowsSetupScriptArguments = [ @@ -50,7 +53,7 @@ export class DoctorService implements IDoctorService { private get jsonFileSettingsPath(): string { return path.join( this.$settingsService.getProfileDir(), - "doctor-cache.json" + "doctor-cache.json", ); } @@ -58,7 +61,7 @@ export class DoctorService implements IDoctorService { private get $jsonFileSettingsService(): IJsonFileSettingsService { return this.$injector.resolve( "jsonFileSettingsService", - { jsonFileSettingsPath: this.jsonFileSettingsPath } + { jsonFileSettingsPath: this.jsonFileSettingsPath }, ); } @@ -72,7 +75,7 @@ export class DoctorService implements IDoctorService { private $fs: IFileSystem, private $terminalSpinnerService: ITerminalSpinnerService, private $versionsService: IVersionsService, - private $settingsService: ISettingsService + private $settingsService: ISettingsService, ) {} public async printWarnings(configOptions?: { @@ -96,17 +99,17 @@ export class DoctorService implements IDoctorService { text: `Getting environment information ${EOL}`, }, () => - this.getInfos({ forceCheck: configOptions.forceCheck }, getInfosData) + this.getInfos({ forceCheck: configOptions.forceCheck }, getInfosData), ); const warnings = infos.filter( - (info) => info.type === constants.WARNING_TYPE_NAME + (info) => info.type === constants.WARNING_TYPE_NAME, ); const hasWarnings = warnings.length > 0; const hasAndroidWarnings = warnings.filter((warning) => - _.includes(warning.platforms, constants.ANDROID_PLATFORM_NAME) + _.includes(warning.platforms, constants.ANDROID_PLATFORM_NAME), ).length > 0; if (hasAndroidWarnings) { this.printPackageManagerTip(); @@ -126,18 +129,18 @@ export class DoctorService implements IDoctorService { this.$logger.info(color.bold("No issues were detected.")); await this.$jsonFileSettingsService.saveSetting( this.getKeyForConfiguration(getInfosData), - infos + infos, ); this.printInfosCore(infos); } try { await this.$versionsService.printVersionsInformation( - configOptions.platform + configOptions.platform, ); } catch (err) { this.$logger.error( - "Cannot get the latest versions information from npm. Please try again later." + "Cannot get the latest versions information from npm. Please try again later.", ); } @@ -146,7 +149,7 @@ export class DoctorService implements IDoctorService { await this.$injector .resolve( - "platformEnvironmentRequirements" + "platformEnvironmentRequirements", ) .checkEnvironmentRequirements({ platform: configOptions.platform, @@ -171,20 +174,20 @@ export class DoctorService implements IDoctorService { } this.$logger.info( - "Running the setup script to try and automatically configure your environment." + "Running the setup script to try and automatically configure your environment.", ); if (this.$hostInfo.isDarwin) { await this.runSetupScriptCore( - DoctorService.DarwinSetupScriptLocation, - [] + DoctorServiceImpl.DarwinSetupScriptLocation, + [], ); } if (this.$hostInfo.isWindows) { await this.runSetupScriptCore( - DoctorService.WindowsSetupScriptExecutable, - DoctorService.WindowsSetupScriptArguments + DoctorServiceImpl.WindowsSetupScriptExecutable, + DoctorServiceImpl.WindowsSetupScriptArguments, ); } @@ -211,7 +214,7 @@ export class DoctorService implements IDoctorService { }; const infos = await this.getInfos( { forceCheck: configuration && configuration.forceCheck }, - sysInfoConfig + sysInfoConfig, ); const warnings = this.filterInfosByType(infos, constants.WARNING_TYPE_NAME); const hasWarnings = warnings.length > 0; @@ -228,7 +231,7 @@ export class DoctorService implements IDoctorService { infos.map((info) => this.$logger.trace(info.message)); await this.$jsonFileSettingsService.saveSetting( this.getKeyForConfiguration(sysInfoConfig), - infos + infos, ); } @@ -243,27 +246,26 @@ export class DoctorService implements IDoctorService { public checkForDeprecatedShortImportsInAppDir(projectDir: string): void { if (projectDir) { try { - const files = this.$projectDataService.getAppExecutableFiles( - projectDir - ); + const files = + this.$projectDataService.getAppExecutableFiles(projectDir); const shortImports = this.getDeprecatedShortImportsInFiles( files, - projectDir + projectDir, ); if (shortImports.length) { this.$logger.printMarkdown( - "Detected short imports in your application. Please note that `short imports are deprecated` since NativeScript 5.2.0. More information can be found in this blogpost https://www.nativescript.org/blog/say-goodbye-to-short-imports-in-nativescript" + "Detected short imports in your application. Please note that `short imports are deprecated` since NativeScript 5.2.0. More information can be found in this blogpost https://www.nativescript.org/blog/say-goodbye-to-short-imports-in-nativescript", ); shortImports.forEach((shortImport) => { this.$logger.printMarkdown( - `In file \`${shortImport.file}\` line \`${shortImport.line}\` is short import. Add \`tns-core-modules/\` in front of the required/imported module.` + `In file \`${shortImport.file}\` line \`${shortImport.line}\` is short import. Add \`tns-core-modules/\` in front of the required/imported module.`, ); }); } } catch (err) { this.$logger.trace( `Unable to validate if project has short imports. Error is`, - err + err, ); } } @@ -271,7 +273,7 @@ export class DoctorService implements IDoctorService { protected getDeprecatedShortImportsInFiles( files: string[], - projectDir: string + projectDir: string, ): { file: string; line: string }[] { const shortImportRegExp = this.getShortImportRegExp(projectDir); const shortImports: { file: string; line: string }[] = []; @@ -280,13 +282,13 @@ export class DoctorService implements IDoctorService { const fileContent = this.$fs.readText(file); const strippedComments = helpers.stripComments(fileContent); const linesToCheck = _.flatten( - strippedComments.split(/\r?\n/).map((line) => line.split(";")) + strippedComments.split(/\r?\n/).map((line) => line.split(";")), ); const linesWithRequireStatements = linesToCheck.filter( (line) => /\btns-core-modules\b/.exec(line) === null && - (/\bimport\b/.exec(line) || /\brequire\b/.exec(line)) + (/\bimport\b/.exec(line) || /\brequire\b/.exec(line)), ); for (const line of linesWithRequireStatements) { @@ -305,14 +307,14 @@ export class DoctorService implements IDoctorService { const pathToTnsCoreModules = path.join( projectDir, NODE_MODULES_FOLDER_NAME, - TNS_CORE_MODULES_NAME + TNS_CORE_MODULES_NAME, ); const coreModulesSubDirs = this.$fs .readDirectory(pathToTnsCoreModules) .filter((entry) => this.$fs .getFsStats(path.join(pathToTnsCoreModules, entry)) - .isDirectory() + .isDirectory(), ); const stringRegularExpressionsPerDir = coreModulesSubDirs.map((c) => { @@ -332,13 +334,13 @@ export class DoctorService implements IDoctorService { private async runSetupScriptCore( executablePath: string, - setupScriptArgs: string[] + setupScriptArgs: string[], ): Promise { return this.$childProcess.spawnFromEvent( executablePath, setupScriptArgs, "close", - { stdio: "inherit" } + { stdio: "inherit" }, ); } @@ -346,12 +348,12 @@ export class DoctorService implements IDoctorService { if (this.$hostInfo.isWindows) { this.$logger.info( "TIP: To avoid setting up the necessary environment variables, you can use the chocolatey package manager to install the Android SDK and its dependencies." + - EOL + EOL, ); } else if (this.$hostInfo.isDarwin) { this.$logger.info( "TIP: To avoid setting up the necessary environment variables, you can use the Homebrew package manager to install the Android SDK and its dependencies." + - EOL + EOL, ); } } @@ -390,20 +392,20 @@ export class DoctorService implements IDoctorService { private filterInfosByType( infos: NativeScriptDoctor.IInfo[], - type: string + type: string, ): NativeScriptDoctor.IInfo[] { return infos.filter((info) => info.type === type); } private getKeyForConfiguration( - sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig + sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig, ): string { const nativeScriptData = sysInfoConfig && sysInfoConfig.projectDir && JSON.stringify( this.$fs.readJson(path.join(sysInfoConfig.projectDir, "package.json")) - .nativescript + .nativescript, ); const delimiter = "__"; const key = [ @@ -426,7 +428,7 @@ export class DoctorService implements IDoctorService { private async getInfos( cacheConfig: { forceCheck: boolean }, - sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig + sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig, ): Promise { const key = this.getKeyForConfiguration(sysInfoConfig); @@ -434,13 +436,13 @@ export class DoctorService implements IDoctorService { ? null : await this.$jsonFileSettingsService.getSettingValue< NativeScriptDoctor.IInfo[] - >(key); + >(key); this.$logger.trace( `getInfos cacheConfig options:`, cacheConfig, " current info from cache: ", - infosFromCache + infosFromCache, ); const infos = infosFromCache || (await doctor.getInfos(sysInfoConfig)); @@ -448,4 +450,4 @@ export class DoctorService implements IDoctorService { return infos; } } -injector.register("doctorService", DoctorService); +injector.register("doctorService", DoctorServiceImpl); diff --git a/lib/services/project-name-service.ts b/lib/services/project-name-service.ts index 5b595d207e..882988d241 100644 --- a/lib/services/project-name-service.ts +++ b/lib/services/project-name-service.ts @@ -3,18 +3,21 @@ import { IProjectNameService } from "../declarations"; import { IErrors } from "../common/declarations"; import * as _ from "lodash"; import { injector } from "../common/yok"; +import { ProjectNameService as ProjectNameServiceContract } from "../contracts/project-name-service"; -export class ProjectNameService implements IProjectNameService { +export class ProjectNameServiceImpl + implements IProjectNameService, ProjectNameServiceContract +{ constructor( private $projectNameValidator: IProjectNameValidator, private $errors: IErrors, private $logger: ILogger, - private $prompter: IPrompter + private $prompter: IPrompter, ) {} public async ensureValidName( projectName: string, - validateOptions?: { force: boolean } + validateOptions?: { force: boolean }, ): Promise { if (validateOptions && validateOptions.force) { return projectName; @@ -24,7 +27,7 @@ export class ProjectNameService implements IProjectNameService { return await this.promptForNewName( "The project name is invalid.", projectName, - validateOptions + validateOptions, ); } @@ -33,28 +36,28 @@ export class ProjectNameService implements IProjectNameService { if (!this.checkIfNameStartsWithLetter(projectName)) { if (!userCanInteract) { this.$errors.fail( - "The project name does not start with letter and will fail to build for Android. If You want to create project with this name add --force to the create command." + "The project name does not start with letter and will fail to build for Android. If You want to create project with this name add --force to the create command.", ); } return await this.promptForNewName( "The project name does not start with letter and will fail to build for Android.", projectName, - validateOptions + validateOptions, ); } if (projectName.toUpperCase() === "APP") { if (!userCanInteract) { this.$errors.fail( - "You cannot build applications named 'app' in Xcode. Consider creating a project with different name. If You want to create project with this name add --force to the create command." + "You cannot build applications named 'app' in Xcode. Consider creating a project with different name. If You want to create project with this name add --force to the create command.", ); } return await this.promptForNewName( "You cannot build applications named 'app' in Xcode. Consider creating a project with different name.", projectName, - validateOptions + validateOptions, ); } @@ -69,27 +72,27 @@ export class ProjectNameService implements IProjectNameService { private async promptForNewName( warningMessage: string, projectName: string, - validateOptions?: { force: boolean } + validateOptions?: { force: boolean }, ): Promise { if (await this.promptForForceNameConfirm(warningMessage)) { return projectName; } const newProjectName = await this.$prompter.getString( - "Enter the new project name:" + "Enter the new project name:", ); return await this.ensureValidName(newProjectName, validateOptions); } private async promptForForceNameConfirm( - warningMessage: string + warningMessage: string, ): Promise { this.$logger.warn(warningMessage); return await this.$prompter.confirm( - "Do you want to create the project with this name?" + "Do you want to create the project with this name?", ); } } -injector.register("projectNameService", ProjectNameService); +injector.register("projectNameService", ProjectNameServiceImpl); diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index b9d7ae0ac2..74af541285 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -14,7 +14,15 @@ const release = process.argv.includes("--release"); // packed, so they have to be mirrored into dist for `npm pack` to bundle them const BUNDLED = ["universal-analytics", "debug", "ms", "uuid"]; -const SIBLING_DIRS = ["resources", "docs", "config", "vendor", "bin", "setup"]; +const SIBLING_DIRS = [ + "resources", + "docs", + "config", + "vendor", + "bin", + "setup", + "contracts", +]; // npm picks README/LICENSE/CHANGELOG up from the directory being packed, so // they have to exist inside dist or they silently drop out of the tarball const ROOT_FILES = [ @@ -37,7 +45,8 @@ function isExcluded(relPath) { return false; } return RELEASE_EXCLUDES.some( - (excluded) => relPath === excluded || relPath.startsWith(excluded + path.sep) + (excluded) => + relPath === excluded || relPath.startsWith(excluded + path.sep), ); } @@ -97,7 +106,7 @@ copyTree( "lib", (relPath) => (!relPath.endsWith(".ts") || relPath.endsWith(".d.ts")) && - !isCompilerOutput(relPath) + !isCompilerOutput(relPath), ); for (const dir of SIBLING_DIRS) { @@ -121,7 +130,7 @@ writeManifest(); function writeManifest() { const pkg = JSON.parse( - fs.readFileSync(path.join(rootDir, "package.json"), "utf8") + fs.readFileSync(path.join(rootDir, "package.json"), "utf8"), ); // dist is the package root once published, so entrypoints lose the dist/ @@ -140,10 +149,10 @@ function writeManifest() { fs.writeFileSync( path.join(distDir, "package.json"), - JSON.stringify(pkg, null, 2) + "\n" + JSON.stringify(pkg, null, 2) + "\n", ); } console.log( - `assets: ${copied} copied, ${skipped} up to date${release ? " (release)" : ""}` + `assets: ${copied} copied, ${skipped} up to date${release ? " (release)" : ""}`, ); diff --git a/test/contracts.ts b/test/contracts.ts new file mode 100644 index 0000000000..c3536f7a4f --- /dev/null +++ b/test/contracts.ts @@ -0,0 +1,31 @@ +import { assert } from "chai"; +import { getContractName, Injector, provide } from "../lib/common/di"; +import { DoctorService, ProjectNameService } from "../lib/contracts"; +import type { ISpawnResult } from "../lib/common/declarations"; + +describe("contracts tranche", () => { + it("carries decorator-set token names matching the Yok-era registrations", () => { + assert.equal(getContractName(DoctorService), "doctorService"); + assert.equal(getContractName(ProjectNameService), "projectNameService"); + }); + + it("resolves via the class, the legacy name, and the $-spelling to one instance", () => { + class StubDoctorService extends DoctorService { + async printWarnings(): Promise {} + async runSetupScript(): Promise { + return {}; + } + async canExecuteLocalBuild(): Promise { + return true; + } + checkForDeprecatedShortImportsInAppDir(): void {} + } + + const injector = new Injector([provide(DoctorService, StubDoctorService)]); + + const byClass = injector.get(DoctorService); + assert.instanceOf(byClass, StubDoctorService); + assert.strictEqual(injector.get("doctorService"), byClass); + assert.strictEqual(injector.get("$doctorService"), byClass); + }); +}); diff --git a/test/project-name-service.ts b/test/project-name-service.ts index 0e62fa70c3..10ec8b10ed 100644 --- a/test/project-name-service.ts +++ b/test/project-name-service.ts @@ -1,5 +1,5 @@ import { Yok } from "../lib/common/yok"; -import { ProjectNameService } from "../lib/services/project-name-service"; +import { ProjectNameServiceImpl as ProjectNameService } from "../lib/services/project-name-service"; import { assert } from "chai"; import { ErrorsStub, LoggerStub } from "./stubs"; import { IProjectNameService } from "../lib/declarations"; diff --git a/test/services/doctor-service.ts b/test/services/doctor-service.ts index 3c87443ff8..862480c02c 100644 --- a/test/services/doctor-service.ts +++ b/test/services/doctor-service.ts @@ -1,4 +1,4 @@ -import { DoctorService } from "../../lib/services/doctor-service"; +import { DoctorServiceImpl as DoctorService } from "../../lib/services/doctor-service"; import { Yok } from "../../lib/common/yok"; import { LoggerStub, FileSystemStub } from "../stubs"; import { assert } from "chai"; @@ -44,7 +44,7 @@ class DoctorServiceInheritor extends DoctorService { $fs: IFileSystem, $terminalSpinnerService: ITerminalSpinnerService, $versionsService: IVersionsService, - $settingsService: ISettingsService + $settingsService: ISettingsService, ) { super( $analyticsService, @@ -56,13 +56,13 @@ class DoctorServiceInheritor extends DoctorService { $fs, $terminalSpinnerService, $versionsService, - $settingsService + $settingsService, ); } public getDeprecatedShortImportsInFiles( files: string[], - projectDir: string + projectDir: string, ): { file: string; line: string }[] { return super.getDeprecatedShortImportsInFiles(files, projectDir); } @@ -81,10 +81,10 @@ describe("doctorService", () => { testInjector.register("terminalSpinnerService", { execute: ( spinnerOptions: ITerminalSpinnerOptions, - action: () => Promise + action: () => Promise, ): Promise => action(), createSpinner: ( - spinnerOptions?: ITerminalSpinnerOptions + spinnerOptions?: ITerminalSpinnerOptions, ): ITerminalSpinner => { text: "", @@ -99,17 +99,17 @@ describe("doctorService", () => { testInjector.register("jsonFileSettingsService", { getSettingValue: async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => undefined, saveSetting: async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => undefined, }); testInjector.register("platformEnvironmentRequirements", { checkEnvironmentRequirements: async ( - input: ICheckEnvironmentRequirementsInput + input: ICheckEnvironmentRequirementsInput, ): Promise => {}, }); @@ -343,8 +343,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl { file: "file1", line: 'const application = require("application")' }, { file: "file1", - line: - 'you should import some long words here require("application") module and other words here`)', + line: 'you should import some long words here require("application") module and other words here`)', }, ], }, @@ -352,9 +351,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl it("getDeprecatedShortImportsInFiles returns correct results", () => { const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const fs = testInjector.resolve("fs"); fs.getFsStats = (file) => { @@ -372,7 +370,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const shortImports = doctorService.getDeprecatedShortImportsInFiles( _.keys(filesContents), - "projectDir" + "projectDir", ); assert.deepStrictEqual(shortImports, expectedShortImports); }); @@ -431,9 +429,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); assert.isTrue(logger.output.indexOf("No issues were detected.") !== -1); @@ -443,15 +440,14 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(failedGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); assert.isTrue( logger.output.indexOf( - "There seem to be issues with your configuration." - ) !== -1 + "There seem to be issues with your configuration.", + ) !== -1, ); }); @@ -459,26 +455,26 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.throws( new Error( - "We should not call @nativescript/doctor package when we have results in the file." - ) + "We should not call @nativescript/doctor package when we have results in the file.", + ), ); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); jsonFileSettingsService.getSettingValue = async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => successGetInfosResult; let saveSettingValue: any = null; jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); @@ -491,17 +487,17 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); let saveSettingValue: any = null; jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); @@ -514,17 +510,17 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); let saveSettingValue: any = null; let isGetSettingValueCalled = false; jsonFileSettingsService.getSettingValue = async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => { isGetSettingValueCalled = true; return null; @@ -532,7 +528,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings({ forceCheck: true }); @@ -541,7 +537,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl assert.isTrue(nsDoctorStub.calledOnce); assert.isFalse( isGetSettingValueCalled, - "When forceCheck is passed, we should not read the cache file." + "When forceCheck is passed, we should not read the cache file.", ); }); @@ -549,9 +545,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(failedGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const fs = testInjector.resolve("fs"); let deletedPath = ""; fs.deleteFile = (filePath: string): void => { @@ -561,8 +556,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl await doctorService.printWarnings(); assert.isTrue( logger.output.indexOf( - "There seem to be issues with your configuration." - ) !== -1 + "There seem to be issues with your configuration.", + ) !== -1, ); assert.isTrue(deletedPath.indexOf("doctor-cache.json") !== -1); }); From b076c541423d50863d8c5ae79df4b4958d438c48 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 20:32:35 -0300 Subject: [PATCH 06/14] test: pin the published injector, hook, and extension contracts Fixtures exercise the surfaces third parties rely on: param-name hook signatures across every payload shape and influence channel (mutation, function-return middleware, abort), require-time extension registration against global.$injector including hierarchical commands, and the full IInjector facade surface. Notably they pin that a hook naming an unwrapped payload key (the after-watchAction shape) is skipped as invalid - resolution changes must not resurrect long-dead hooks. --- test/compat/injector-facade-surface.ts | 75 ++++++ test/compat/legacy-extension.ts | 110 +++++++++ test/compat/legacy-hooks.ts | 321 +++++++++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 test/compat/injector-facade-surface.ts create mode 100644 test/compat/legacy-extension.ts create mode 100644 test/compat/legacy-hooks.ts diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts new file mode 100644 index 0000000000..935709f246 --- /dev/null +++ b/test/compat/injector-facade-surface.ts @@ -0,0 +1,75 @@ +import { assert } from "chai"; +import { Yok } from "../../lib/common/yok"; + +// Pins the externally reachable injector surface: every IInjector member +// (lib/common/definitions/yok.d.ts) plus dispose, subclassability, the +// injector self-registration, and the global assignment. The Yok facade must +// keep all of these behaving through the DI migration. + +const FACADE_METHODS = [ + "require", + "requirePublic", + "requirePublicClass", + "requireCommand", + "requireKeyCommand", + "resolve", + "resolveCommand", + "resolveKeyCommand", + "register", + "registerCommand", + "registerKeyCommand", + "getRegisteredCommandsNames", + "getRegisteredKeyCommandsNames", + "dynamicCall", + "getDynamicCallData", + "isDefaultCommand", + "isValidHierarchicalCommand", + "getChildrenCommandsNames", + "buildHierarchicalCommand", + "dispose", +]; + +describe("injector facade surface", () => { + it("exposes every IInjector member", () => { + const inj: any = new Yok(); + for (const member of FACADE_METHODS) { + assert.isFunction(inj[member], `missing facade method: ${member}`); + } + assert.instanceOf(inj.dynamicCallRegex, RegExp); + assert.isObject(inj.publicApi); + assert.isObject(inj.publicApi.__modules__); + assert.isBoolean(inj.overrideAlreadyRequiredModule); + }); + + it("registers itself under 'injector', resolvable with and without the $ prefix", () => { + const inj = new Yok(); + assert.strictEqual(inj.resolve("injector"), inj); + assert.strictEqual(inj.resolve("$injector"), inj); + }); + + it("remains subclassable (the InjectorStub pattern in test/stubs.ts)", () => { + class SubInjector extends Yok {} + const sub = new SubInjector(); + sub.register("subclassed", { value: 42 }); + assert.equal(sub.resolve("subclassed").value, 42); + assert.strictEqual(sub.resolve("injector"), sub); + }); + + it("assigns the process-wide global.$injector", () => { + assert.isOk((global).$injector); + for (const member of FACADE_METHODS) { + assert.isFunction( + (global).$injector[member], + `global.$injector missing: ${member}`, + ); + } + }); + + it("calls lowercase/anonymous resolvers as factories instead of new-ing them", () => { + const inj = new Yok(); + inj.register("factoryMade", function () { + return { madeByFactory: true }; + }); + assert.isTrue(inj.resolve("factoryMade").madeByFactory); + }); +}); diff --git a/test/compat/legacy-extension.ts b/test/compat/legacy-extension.ts new file mode 100644 index 0000000000..42acdbd0da --- /dev/null +++ b/test/compat/legacy-extension.ts @@ -0,0 +1,110 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ICliGlobal } from "../../lib/common/definitions/cli-global"; + +// Pins the published extension contract: an extension is require()d and +// registers its contributions by mutating global.$injector — commands via +// requireCommand/registerCommand (lazy, path-based), services via register. +// extending-cli.md advertises this surface. + +const cliGlobal = (global); + +describe("legacy extension contract", () => { + let extDir: string; + let capture: any; + + beforeEach(() => { + extDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-compat-ext-")); + capture = (global).__extCapture = {}; + }); + + afterEach(() => { + fs.rmSync(extDir, { recursive: true, force: true }); + delete (global).__extCapture; + }); + + it("an unmodified extension contributes services and hierarchical commands via global.$injector", async () => { + const commandPath = path.join(extDir, "meow-command"); + fs.writeFileSync( + commandPath + ".js", + `class MeowPurrCommand { + constructor($extCompatService) { + this.extCompatService = $extCompatService; + this.allowedParameters = []; + } + async execute(args) { + global.__extCapture.executedWith = args; + global.__extCapture.service = this.extCompatService; + } + } + global.$injector.registerCommand("meowcompat|purr", MeowPurrCommand);`, + ); + fs.writeFileSync( + path.join(extDir, "main.js"), + `global.$injector.register("extCompatService", { name: "ext-compat-service" }); + global.$injector.requireCommand("meowcompat|purr", ${JSON.stringify( + commandPath, + )});`, + ); + + require(path.join(extDir, "main.js")); + + // Registration is lazy: the command module must not load until resolved. + assert.isUndefined(capture.executedWith); + + // Services registered by the extension resolve under both spellings. + const service = cliGlobal.$injector.resolve("extCompatService"); + assert.strictEqual( + cliGlobal.$injector.resolve("$extCompatService"), + service, + ); + assert.equal(service.name, "ext-compat-service"); + + // The command participates in the hierarchical router. + assert.include( + cliGlobal.$injector.getRegisteredCommandsNames(false), + "meowcompat|purr", + ); + assert.include( + cliGlobal.$injector.getChildrenCommandsNames("meowcompat"), + "purr", + ); + const built = cliGlobal.$injector.buildHierarchicalCommand("meowcompat", [ + "purr", + "extra-arg", + ]); + assert.equal(built.commandName, "meowcompat|purr"); + assert.deepEqual(built.remainingArguments, ["extra-arg"]); + + // The leaf command resolves, gets its DI-injected service, and executes. + const command = cliGlobal.$injector.resolveCommand("meowcompat|purr"); + assert.isOk(command); + await command.execute(["fluffy"]); + assert.deepEqual(capture.executedWith, ["fluffy"]); + assert.equal(capture.service.name, "ext-compat-service"); + + // registerCommand on a hierarchical name synthesized a parent dispatcher. + const parent = cliGlobal.$injector.resolveCommand("meowcompat"); + assert.isTrue(parent.isHierarchicalCommand); + }); + + it("claiming an already-required command name throws unless overrideAlreadyRequiredModule is set", () => { + const firstPath = path.join(extDir, "first"); + fs.writeFileSync(firstPath + ".js", `module.exports = {};`); + + cliGlobal.$injector.requireCommand("conflictcompat", firstPath); + assert.throws( + () => cliGlobal.$injector.requireCommand("conflictcompat", firstPath), + /require'd twice/, + ); + + cliGlobal.$injector.overrideAlreadyRequiredModule = true; + try { + cliGlobal.$injector.requireCommand("conflictcompat", firstPath); + } finally { + cliGlobal.$injector.overrideAlreadyRequiredModule = false; + } + }); +}); diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts new file mode 100644 index 0000000000..4dc91e89da --- /dev/null +++ b/test/compat/legacy-hooks.ts @@ -0,0 +1,321 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok } from "../../lib/common/yok"; +import { HooksService } from "../../lib/common/services/hooks-service"; +import { hook } from "../../lib/common/helpers"; +import { IInjector } from "../../lib/common/definitions/yok"; +import { IHooksService } from "../../lib/common/declarations"; +import { LoggerStub, ErrorsStub } from "../stubs"; + +// Pins the published third-party hook contract: hooks are plain JS files whose +// exported function is resolved by its own parameter names (`$logger`, +// `hookArgs`, ...). Payload shapes and influence channels covered here are the +// compatibility bar for any DI changes. + +function createTestInjector(projectDir: string): IInjector { + const testInjector = new Yok(); + testInjector.register("logger", LoggerStub); + testInjector.register("errors", ErrorsStub); + testInjector.register("fs", { + exists: (p: string) => fs.existsSync(p), + getFsStats: (p: string) => fs.statSync(p), + readDirectory: (p: string) => fs.readdirSync(p), + readText: (p: string) => fs.readFileSync(p, "utf8"), + }); + testInjector.register("childProcess", {}); + testInjector.register("config", { DISABLE_HOOKS: false }); + testInjector.register("staticConfig", { + CLIENT_NAME: "tns", + version: "0.0.0", + }); + testInjector.register("projectHelper", { projectDir }); + testInjector.register("options", { hooks: true }); + testInjector.register("performanceService", { + now: () => 0, + processExecutionData: () => { + /* not measured here */ + }, + }); + testInjector.register("projectConfigService", { + getValue: (_key: string, defaultValue: any) => defaultValue, + }); + testInjector.register("projectData", { fromContainer: true }); + testInjector.register("hooksService", HooksService); + return testInjector; +} + +function writeHook(projectDir: string, hookName: string, source: string): void { + const hooksDir = path.join(projectDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, `${hookName}.js`), source); +} + +describe("legacy hook contract", () => { + let projectDir: string; + let testInjector: IInjector; + let capture: any; + + const hooksService = (): IHooksService => + testInjector.resolve("hooksService"); + const logger = (): LoggerStub => testInjector.resolve("logger"); + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-compat-hooks-")); + testInjector = createTestInjector(projectDir); + capture = (global).__hookCapture = {}; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + delete (global).__hookCapture; + }); + + it("injects services by $-prefixed parameter name and passes hookArgs by identity", async () => { + writeHook( + projectDir, + "before-case1", + `module.exports = function ($logger, $injector, hookArgs) { + global.__hookCapture.logger = $logger; + global.__hookCapture.injector = $injector; + global.__hookCapture.hookArgs = hookArgs; + };`, + ); + + const payload = { anything: 1 }; + await hooksService().executeBeforeHooks("case1", { hookArgs: payload }); + + assert.strictEqual(capture.logger, testInjector.resolve("logger")); + assert.strictEqual(capture.injector, testInjector); + assert.strictEqual(capture.hookArgs, payload); + assert.include(logger().traceOutput, "hooks.param-name-signature"); + }); + + it("derives the hook name from the pre-| part of hierarchical command names", async () => { + writeHook( + projectDir, + "before-case2", + `module.exports = function (hookArgs) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeBeforeHooks("case2|android", { + hookArgs: {}, + }); + + assert.isTrue(capture.ran); + }); + + it("promotes hookArgs.projectData so it shadows the container's projectData under both spellings", async () => { + writeHook( + projectDir, + "after-case3", + `module.exports = function ($projectData, projectData) { + global.__hookCapture.dollar = $projectData; + global.__hookCapture.plain = projectData; + };`, + ); + + const payloadProjectData = { projectDir, fromPayload: true }; + await hooksService().executeAfterHooks("case3", { + hookArgs: { projectData: payloadProjectData }, + }); + + assert.strictEqual(capture.dollar, payloadProjectData); + assert.strictEqual(capture.plain, payloadProjectData); + }); + + it("lets a hook mutate the payload in a way the caller observes (before-build-task-args channel)", async () => { + writeHook( + projectDir, + "before-build-task-args", + `module.exports = function (hookArgs) { hookArgs.args.push("--offline"); };`, + ); + + const args = ["assembleDebug"]; + await hooksService().executeBeforeHooks("build-task-args", { + hookArgs: { args }, + }); + + assert.deepEqual(args, ["assembleDebug", "--offline"]); + }); + + it("treats a rejection carrying stopExecution + errorAsWarning as a warning, not a failure", async () => { + writeHook( + projectDir, + "before-case5", + `module.exports = async function () { + const err = new Error("abort-as-warning"); + err.stopExecution = false; + err.errorAsWarning = true; + throw err; + };`, + ); + + await hooksService().executeBeforeHooks("case5"); + + assert.include(logger().warnOutput, "abort-as-warning"); + }); + + it("fails command execution when a hook rejects without errorAsWarning", async () => { + writeHook( + projectDir, + "before-case6", + `module.exports = async function () { throw new Error("hard-abort"); };`, + ); + + await assert.isRejected( + hooksService().executeBeforeHooks("case6"), + /hard-abort/, + ); + }); + + it("runs hooks with no payload at all (every command name is a hook point)", async () => { + writeHook( + projectDir, + "before-case7", + `module.exports = function ($logger) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeBeforeHooks("case7"); + + assert.isTrue(capture.ran); + }); + + it("skips (with a warning) a hook whose parameters name unwrapped payload keys", async () => { + // after-watchAction-style payloads pass keys at the top level with no + // hookArgs wrapper. validateHookArguments only consults the container, so + // a hook naming such a key is skipped as invalid — today's behavior, which + // the migration must preserve exactly. + writeHook( + projectDir, + "after-case8", + `module.exports = function (liveSyncResultInfo) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeAfterHooks("case8", { + liveSyncResultInfo: { fake: true }, + }); + + assert.isUndefined(capture.ran); + assert.include(logger().warnOutput, "invalid arguments"); + }); + + it("folds a function returned by a before-hook into a middleware chain around the @hook-decorated method", async () => { + writeHook( + projectDir, + "before-case9", + `module.exports = function (hookArgs) { + return function (args, originalMethod) { + global.__hookCapture.middlewareArgs = args.slice(); + return originalMethod.apply(null, args).then(function (result) { + return "wrapped(" + result + ")"; + }); + }; + };`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case9") + async doWork(input: string): Promise { + (global).__hookCapture.originalRan = true; + return "original:" + input; + } + } + + const subject = testInjector.resolve(Subject); + const result = await subject.doWork("x"); + + assert.equal(result, "wrapped(original:x)"); + assert.isTrue(capture.originalRan); + assert.deepEqual(capture.middlewareArgs, ["x"]); + }); + + it("lets a middleware short-circuit so the original method never runs", async () => { + writeHook( + projectDir, + "before-case10", + `module.exports = function () { + return function (args, originalMethod) { + return "short-circuited"; + }; + };`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case10") + async doWork(): Promise { + (global).__hookCapture.originalRan = true; + return "original"; + } + } + + const subject = testInjector.resolve(Subject); + const result = await subject.doWork(); + + assert.equal(result, "short-circuited"); + assert.isUndefined(capture.originalRan); + }); + + it("@hook falls back to the global injector when the class has neither $hooksService nor $injector", async () => { + writeHook( + projectDir, + "before-case11", + `module.exports = function () { global.__hookCapture.ran = true; };`, + ); + + class Subject { + @hook("case11") + async doWork(): Promise { + return "ok"; + } + } + + const globalAny = global; + const previousInjector = globalAny.$injector; + globalAny.$injector = testInjector; + try { + const result = await new Subject().doWork(); + assert.equal(result, "ok"); + assert.isTrue(capture.ran); + } finally { + globalAny.$injector = previousInjector; + } + }); + + it("@hook prefers the instance's $injector over the global injector", async () => { + writeHook( + projectDir, + "before-case12", + `module.exports = function () { global.__hookCapture.ran = true; };`, + ); + + class Subject { + public $injector = testInjector; + + @hook("case12") + async doWork(): Promise { + return "ok"; + } + } + + const globalAny = global; + const previousInjector = globalAny.$injector; + globalAny.$injector = { + resolve: () => { + throw new Error("global injector must be the last resort"); + }, + }; + try { + const result = await new Subject().doWork(); + assert.equal(result, "ok"); + assert.isTrue(capture.ran); + } finally { + globalAny.$injector = previousInjector; + } + }); +}); From a23a9017ebd6e93ef7592488d5592d760f44cf11 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 21:17:43 -0300 Subject: [PATCH 07/14] docs: document the token-based injection API dependency-injection.md covers tokens (@Contract), inject()/Injector, provider kinds, resolution semantics, forwardRef, coexistence with the legacy $injector, and a legacy-to-new quick reference. extending-cli.md leads with the recommended hook pattern - inject() works directly in hook bodies because they run in an injection context, pinned by a new compat test - and demotes parameter-name injection to a labeled legacy section. String-token lookups are framed as the migration bridge, not a co-equal API. The hook deprecation tracer now flags only hooks that actually use param-name service injection; a hookArgs-only signature follows the recommended pattern and stays silent. --- dependency-injection.md | 258 +++++++++++++++++++++++++++ extending-cli.md | 45 +++-- lib/common/services/hooks-service.ts | 18 +- test/compat/legacy-hooks.ts | 26 +++ 4 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 dependency-injection.md diff --git a/dependency-injection.md b/dependency-injection.md new file mode 100644 index 0000000000..fc8a6ae9e5 --- /dev/null +++ b/dependency-injection.md @@ -0,0 +1,258 @@ +Dependency Injection +==================== + +The NativeScript CLI is migrating from name-based dependency injection (the +`$injector` global, where a constructor parameter named `$doctorService` +resolves the service registered under the string `"doctorService"`) to a typed, +token-based container. Both APIs are backed by **one container**, so they can be +mixed freely: a service registered under a legacy string name is resolvable +through its typed token and vice versa. The legacy `$injector` surface remains +fully supported, is marked `@deprecated` in-editor, and its usage is traced at +runtime so removal can be staged over releases. + +Inside the CLI, import from `lib/common/di`. Extension and hook authors import +the same API from the `nativescript/contracts` subpath (see +[For extension and hook authors](#for-extension-and-hook-authors)). + +At a glance +----------- + +```ts +import { inject, DoctorService } from "nativescript/contracts"; + +class PlatformChecker { + private doctorService = inject(DoctorService); // typed, no decorators needed + + async check(projectDir: string): Promise { + return this.doctorService.canExecuteLocalBuild({ projectDir }); + } +} +``` + +Services that have no typed token yet remain reachable by their registry name — +`inject("logger")` — but that is the migration bridge, not the API: prefer the +token wherever one exists, and mint a token rather than a new string name. + +Tokens: `@Contract` +------------------- + +A token is an abstract class annotated with `@Contract`. The class is both the +compile-time type and the runtime lookup key; the decorator records the token's +canonical string name: + +```ts +import { Contract } from "nativescript/contracts"; + +@Contract({ name: "doctorService" }) // canonical form, no `$` +export abstract class DoctorService { + abstract canExecuteLocalBuild(configuration?: { + platform?: string; + projectDir?: string; + }): Promise; +} +``` + +Rules: + +- **The name is an explicit string literal** — never derived from `class.name`, + which changes under minification. +- Names are minted at this single choke point: declaring two contracts with the + same name **throws at load time**, because a duplicate would silently alias + two tokens. +- The options object leaves room for future fields without changing call sites. +- Implementations do not become tokens by extending or implementing a contract; + only the decorated class itself is a token. + +Resolving: `inject()` and `Injector` +------------------------------------ + +`inject(token)` returns the singleton for a token from the current injection +context. It is **synchronous by design** and valid only: + +- in field initializers, +- in constructor bodies, +- in provider factories, +- inside an explicit `runInInjectionContext(injector, fn)`. + +It is **not** valid after an `await`. For late or conditional lookups, +self-inject the `Injector` and use `get()`: + +```ts +import { inject, Injector } from "nativescript/contracts"; + +class EnvironmentChecker { + private injector = inject(Injector); + + async check(projectDir: string) { + await somethingAsync(); + return this.injector.get(DoctorService); // fine after await + } +} +``` + +`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed +string name — all three return the same instance. The string forms exist for +interoperability with the legacy registry; use the class token whenever one +exists. + +Registering: providers +---------------------- + +```ts +import { provide, provideLazy, Injector } from "nativescript/contracts"; + +const injector = new Injector([ + // eager class binding; type-checked: the impl must satisfy the token + provide(DoctorService, DoctorServiceImpl), + + // deferred loading: the module is require()d on first resolution only + provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl), + + { provide: Config, useValue: { DISABLE_HOOKS: false } }, + { provide: Dispatcher, useFactory: () => createDispatcher(), shared: false }, +]); + +// registration is also allowed after construction; re-registering a token +// updates the existing record in place +injector.register(provide(ProjectNameService, ProjectNameServiceImpl)); +``` + +Provider kinds: + +| Kind | Shape | Notes | +|---|---|---| +| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields | +| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy | +| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance | +| Factory | `{ provide, useFactory }` | called inside an injection context | + +`shared: false` makes a provider transient: every resolution constructs a fresh +instance. Transient instances are still retained by the container so +`dispose()` reaches them. + +String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`) +— that is how the legacy facade registers, and how per-call overrides address +not-yet-migrated dependencies. New registrations should mint a `@Contract` +token instead of a new string name. + +For per-call construction with overrides (a fresh instance of a class with some +dependencies replaced), use `createInstance`: + +```ts +const debugService = injector.createInstance(IOSDeviceDebugService, [ + { provide: "device", useValue: device }, +]); +``` + +Overrides shadow **one level deep only** — the direct dependencies of the class +being constructed. Nested dependencies are constructed by the injector that +owns them and never see the per-call providers. + +Resolution semantics +-------------------- + +- Lookup is **class object first, token name on a miss**, checked per injector + level before delegating to the parent. Both keys index the same provider + record, so re-registering a service by its string name (as plugins are + documented to do with `$logger`) stays visible to `inject(Logger)` consumers. +- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")` + are the same registration. +- The name fallback also makes **duplicated contract copies interchangeable**: + if an extension's dependency tree carries its own copy of a contract class, + that copy resolves to the same provider by name. "Works locally, breaks when + installed" is not a failure mode of this design. +- Cyclic dependencies fail with the full resolution path + (`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`). + +Child scopes +------------ + +`injector.createChild(providers)` creates a scope that shadows its parent for +the given tokens and falls through for everything else. Sibling scopes are +isolated. Scopes are how per-invocation data (hook payloads, per-call +overrides) is layered over the shared singletons without ever entering the +root container. + +`forwardRef` +------------ + +Provider arrays are evaluated at module load. When a token is declared later in +the same file (TDZ) or reached through a circular import, wrap the reference in +a thunk; it is read only when the injector processes the provider: + +```ts +import { forwardRef } from "nativescript/contracts"; + +const providers = [ + { provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl }, +]; +``` + +`forwardRef` defers *references*, not construction — it cannot break an +instantiation cycle between two services. For that, self-inject the `Injector` +and resolve late (see above). + +Working alongside the legacy `$injector` +---------------------------------------- + +The `Yok` facade (`global.$injector`) delegates to the token-based container, +exposed as `$injector.di`. Everything is one registry: + +```ts +$injector.resolve("doctorService") === $injector.di.get(DoctorService); // true +``` + +- Legacy string names are permanent: a contract's token name is its interop + identity, used by hooks, plugins, and the public API. Nothing is deleted + per-service. +- Every legacy member (`resolve`, `register`, `require*`, the command-registry + surface) carries `@deprecated` JSDoc naming its replacement. +- Legacy usage at the external entry points (param-name hooks, require-time + extension registration, help templating) is reported through a deprecation + tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or + `NS_DEPRECATIONS=error` to preview the stricter stages that later releases + will default to. + +For extension and hook authors +------------------------------ + +Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency` +for local development) and import from the `contracts` subpath: + +```ts +import { inject, DoctorService } from "nativescript/contracts"; +``` + +- The subpath resolves through a directory `package.json` — the CLI's + `package.json` deliberately has **no `exports` map**, so any deep `require()` + paths you already use keep working. +- The entry point is side-effect-free: importing it never boots a CLI runtime, + even from a duplicated copy in your dependency tree. +- The existing `$injector`-based extension and hook mechanisms keep working + unchanged; the typed API is additive. + +Available contracts +------------------- + +The first tranche, growing as services migrate: + +| Token | Legacy name | +|---|---| +| `DoctorService` | `doctorService` | +| `ProjectNameService` | `projectNameService` | + +Legacy → new quick reference +---------------------------- + +`di` below is the token-based container backing the facade (`$injector.di`), +or any `Injector` you hold directly. + +| Legacy (`$injector`) | New | +|---|---| +| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` | +| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` | +| `register("name", Impl)` | `di.register(provide(Token, Impl))` | +| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` | +| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` | +| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` | +| constructor param `$name` | `inject(Token)` field initializer | diff --git a/extending-cli.md b/extending-cli.md index 3a7b5387b5..d24fd55d45 100644 --- a/extending-cli.md +++ b/extending-cli.md @@ -77,15 +77,29 @@ Execute Hooks In-Process When your hook is a Node.js script, the CLI executes it in-process. This gives you access to the entire internal state of the CLI and all of its functions. -The CLI assumes that this is a CommonJS module and calls its single exported function with four parameters. The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions) and [here](https://github.com/telerik/mobile-cli-lib/tree/master/definitions). +The CLI assumes that this is a CommonJS module and calls its single exported function. + +## Writing a hook + +Hooks run inside an injection context, so services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)). Declare a `hookArgs` parameter only if you need the payload of the operation being hooked. + +```JavaScript +const { inject, DoctorService } = require("nativescript/contracts"); + +module.exports = function (hookArgs) { + const doctorService = inject(DoctorService); + return doctorService.canExecuteLocalBuild(); +}; +``` + +* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)`, then `injector.get(...)` later. +* `hookArgs` contains the parameters of the CLI function being hooked; its shape depends on the hook point. Declare it only when you need it — a hook may also take no parameters at all. A future typed hook API (`defineHook` with an explicit context object) will replace this parameter; it is the one remaining piece of the legacy convention. +* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service. +* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge. +* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { inject, DoctorService } from "nativescript/contracts"`. + +## The hook contract -Parameter | Type | Description ----|---|--- -`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state. -`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc. -`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress. -`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked. - The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored. The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI. @@ -95,8 +109,19 @@ Member | Type | Description `errorAsWarning` | Boolean | Set this to treat the returned error as warning. The CLI prints the error.message colored as a warning and continues executing the current command. If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command. - -Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available. + +## Legacy: parameter-name injection + +Historically, a hook received CLI services by naming them as parameters: the CLI parses the exported function's parameter names and injects the service registered under each name. Existing hooks written this way keep working unchanged, but **new hooks should use the pattern above** — parameter-name service injection is slated for deprecation, and hooks that use it are reported through the CLI's deprecation tracer (visible with `--log trace`, or as warnings with `NS_DEPRECATIONS=warn`). + +Parameter | Type | Description +---|---|--- +`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state. +`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc. +`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress. +`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked. + +The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions). Any registered service name is injectable, not only the ones listed; the global variable `$injector` of type `IInjector` likewise remains available. A parameter the CLI cannot resolve causes the hook to be skipped with a warning. Commands with Hooking Support ============================== diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index 0d58823d4b..a9cada85c9 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -246,11 +246,19 @@ export class HooksService implements IHooksService { projectDataHookArg; } - reportDeprecation({ - api: "hooks.param-name-signature", - detail: hook.fullPath, - logger: this.$logger, - }); + // Only param-name *service* injection is on the deprecation track; a + // hook declaring nothing but `hookArgs` (or no parameters) already + // follows the recommended pattern and must not be flagged. + const usesParamNameInjection = (( + hookEntryPoint.$inject.args + )).some((argument) => argument !== this.hookArgsName); + if (usesParamNameInjection) { + reportDeprecation({ + api: "hooks.param-name-signature", + detail: hook.fullPath, + logger: this.$logger, + }); + } const maybePromise = this.$injector.resolve( hookEntryPoint, diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index 4dc91e89da..bd792205cf 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -138,6 +138,9 @@ describe("legacy hook contract", () => { }); assert.deepEqual(args, ["assembleDebug", "--offline"]); + // A hookArgs-only signature is the recommended pattern and must not be + // reported as param-name injection. + assert.notInclude(logger().traceOutput, "hooks.param-name-signature"); }); it("treats a rejection carrying stopExecution + errorAsWarning as a warning, not a failure", async () => { @@ -261,6 +264,29 @@ describe("legacy hook contract", () => { assert.isUndefined(capture.originalRan); }); + it("runs hook bodies in an injection context, so inject() resolves services", async () => { + const diPath = require.resolve("../../lib/common/di"); + writeHook( + projectDir, + "before-case-inject", + `const { inject, Injector } = require(${JSON.stringify(diPath)}); + module.exports = function (hookArgs) { + global.__hookCapture.logger = inject("logger"); + global.__hookCapture.container = inject(Injector); + global.__hookCapture.hookArgs = hookArgs; + };`, + ); + + const payload = { sample: true }; + await hooksService().executeBeforeHooks("case-inject", { + hookArgs: payload, + }); + + assert.strictEqual(capture.logger, testInjector.resolve("logger")); + assert.strictEqual(capture.container, (testInjector).di); + assert.strictEqual(capture.hookArgs, payload); + }); + it("@hook falls back to the global injector when the class has neither $hooksService nor $injector", async () => { writeHook( projectDir, From 55a507eaec0f34fa4c82b07f04be9aa456f0dc96 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:33:23 -0300 Subject: [PATCH 08/14] fix(yok): keep hierarchical dispatchers on the owning injector createHierarchicalCommand registered the synthesized parent - and its execute path resolved commandsService and errors - through the module-level global injector instead of the instance it was called on, so a parent dispatcher leaked onto the global injector whenever a hierarchical command was registered on any other instance. The require-ordering guard accidentally depended on that leak: with the parent now landing on the same instance, it is exempted for synthesized parents (they exist because a child registered, not because requires ran out of order), and the else branch mirrors the existing default-command skip. Net effect: register-then-require orderings that used to throw 'Default commands should be required before child commands' now work; nothing that worked before changes. --- lib/common/yok.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 9bc3a7bab5..3505487119 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -67,6 +67,12 @@ export class Yok implements IInjector { } private COMMANDS_NAMESPACE: string = "commands"; + /** + * Parents whose dispatcher THIS instance synthesized. The require-ordering + * guard below must not fire for them: they exist because a child was + * registered, not because child requires ran out of order. + */ + private synthesizedParents = new Set(); private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; private hierarchicalCommands: IDictionary = {}; @@ -88,7 +94,8 @@ export class Yok implements IInjector { if (commands.length > 1) { if ( _.startsWith(commands[1], "*") && - this.container.has(this.createCommandName(commands[0])) + this.container.has(this.createCommandName(commands[0])) && + !this.synthesizedParents.has(commands[0]) ) { throw new Error( "Default commands should be required before child commands", @@ -114,7 +121,9 @@ export class Yok implements IInjector { if (commands[1] && !commandName.match(/\|\*/)) { this.require(this.createCommandName(commandName), file); } - } else { + } else if (!commandName.match(/\|\*/)) { + // Mirrors the default-command skip of the branch above: a default's + // own record comes from registerCommand, never from a require path. this.require(this.createCommandName(commandName), file); } }); @@ -305,12 +314,13 @@ export class Yok implements IInjector { } private createHierarchicalCommand(name: string) { + this.synthesizedParents.add(name); const factory = () => { return { disableAnalytics: true, isHierarchicalCommand: true, execute: async (args: string[]): Promise => { - const commandsService = injector.resolve("commandsService"); + const commandsService = this.resolve("commandsService"); let commandName: string = null; const defaultCommand = this.getDefaultCommand(name, args); let commandArguments: ICommandArgument[] = []; @@ -364,7 +374,7 @@ export class Yok implements IInjector { }; }; - injector.registerCommand(name, factory); + this.registerCommand(name, factory); } private getHierarchicalCommandName( @@ -395,7 +405,7 @@ export class Yok implements IInjector { // In case buildHierarchicalCommand doesn't find a valid command // there isn't a valid command or default with those arguments - const errors = injector.resolve("errors"); + const errors = this.resolve("errors"); errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, commandName); } From 9bc9739415c7b714d7285daaff23a7a1c26d1e75 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:37:51 -0300 Subject: [PATCH 09/14] refactor: reach the process-wide injector through getInjector() Internal code no longer reads global.$injector: the two cycle-bound sites (the deprecation tracer's logger fallback and @hook's last-resort lookup) go through a getInjector() accessor required at call time. The global property remains write-only from the CLI's side - it exists as the published surface for extensions and hooks. --- lib/common/deprecation.ts | 8 +++++--- lib/common/helpers.ts | 7 ++++--- lib/common/yok.ts | 11 +++++++++++ test/compat/legacy-hooks.ts | 20 +++++++++----------- test/deprecation.ts | 18 ++++++++---------- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts index b30eb984b6..6772be9a9e 100644 --- a/lib/common/deprecation.ts +++ b/lib/common/deprecation.ts @@ -80,11 +80,13 @@ function getDeprecationStage(): DeprecationStage { function tryResolveGlobalLogger(): IDeprecationLogger | null { try { - const globalInjector = (global).$injector; - if (!globalInjector) { + // Required at call time: yok imports this module, so a static import + // would be a cycle. Every reporting site already runs with yok loaded. + const injector = require("./yok").getInjector(); + if (!injector) { return null; } - return globalInjector.resolve("logger"); + return injector.resolve("logger"); } catch (err) { return null; } diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 299ad373e8..d688eafb3d 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -567,10 +567,11 @@ export function hook(commandName: string) { function getHooksService(self: any): IHooksService { let hooksService: IHooksService = self.$hooksService; if (!hooksService) { - // The global injector must stay the LAST resort: tests stub + // The process-wide injector must stay the LAST resort: tests stub // self.$hooksService / self.$injector, and a class migrated off - // property injection has neither — only then may the global be used. - const injector = self.$injector || (global).$injector; + // property injection has neither — only then may it be used. It is + // required at call time because yok imports this module (cycle). + const injector = self.$injector || require("./yok").getInjector(); if (!injector) { throw Error( "Type with hooks needs to have either $hooksService or $injector injected.", diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 3505487119..7c94b2a6ba 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -589,6 +589,17 @@ if (!(global).$injector) { injector = (global).$injector; } +/** + * Accessor for the process-wide facade, for code that cannot receive the + * injector through DI or a static import (import cycles, decorator bodies). + * Prefer inject(Injector) in an injection context; prefer a constructor + * dependency in services. Never read global.$injector directly — the global + * exists only as the published legacy surface for extensions and hooks. + */ +export function getInjector(): IInjector { + return injector; +} + /** * @deprecated Global-singleton wiring for the legacy facade; new code receives * the container via inject(Injector) instead of a process-wide global. diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index bd792205cf..eb4c565384 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -2,7 +2,7 @@ import { assert } from "chai"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { Yok } from "../../lib/common/yok"; +import { Yok, getInjector, setGlobalInjector } from "../../lib/common/yok"; import { HooksService } from "../../lib/common/services/hooks-service"; import { hook } from "../../lib/common/helpers"; import { IInjector } from "../../lib/common/definitions/yok"; @@ -301,15 +301,14 @@ describe("legacy hook contract", () => { } } - const globalAny = global; - const previousInjector = globalAny.$injector; - globalAny.$injector = testInjector; + const previousInjector = getInjector(); + setGlobalInjector(testInjector); try { const result = await new Subject().doWork(); assert.equal(result, "ok"); assert.isTrue(capture.ran); } finally { - globalAny.$injector = previousInjector; + setGlobalInjector(previousInjector); } }); @@ -329,19 +328,18 @@ describe("legacy hook contract", () => { } } - const globalAny = global; - const previousInjector = globalAny.$injector; - globalAny.$injector = { + const previousInjector = getInjector(); + setGlobalInjector({ resolve: () => { - throw new Error("global injector must be the last resort"); + throw new Error("the process-wide injector must be the last resort"); }, - }; + }); try { const result = await new Subject().doWork(); assert.equal(result, "ok"); assert.isTrue(capture.ran); } finally { - globalAny.$injector = previousInjector; + setGlobalInjector(previousInjector); } }); }); diff --git a/test/deprecation.ts b/test/deprecation.ts index 4d1fd681c3..91af413cdf 100644 --- a/test/deprecation.ts +++ b/test/deprecation.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { Yok } from "../lib/common/yok"; +import { Yok, getInjector, setGlobalInjector } from "../lib/common/yok"; import { reportDeprecation, clearReportedDeprecations, @@ -59,31 +59,29 @@ describe("deprecation tracer", () => { ); }); - it("falls back to the global injector's logger when none is passed", () => { - const globalAny = global; - const previousInjector = globalAny.$injector; + it("falls back to the process-wide injector's logger when none is passed", () => { + const previousInjector = getInjector(); const freshInjector = new Yok(); const freshLogger = new LoggerStub(); freshInjector.register("logger", freshLogger); - globalAny.$injector = freshInjector; + setGlobalInjector(freshInjector); try { reportDeprecation({ api: "test.global-logger" }); assert.include(freshLogger.traceOutput, "test.global-logger"); } finally { - globalAny.$injector = previousInjector; + setGlobalInjector(previousInjector); } }); it("drops the report silently when no logger is resolvable", () => { - const globalAny = global; - const previousInjector = globalAny.$injector; - globalAny.$injector = undefined; + const previousInjector = getInjector(); + setGlobalInjector(new Yok()); try { assert.doesNotThrow(() => reportDeprecation({ api: "test.no-logger" })); } finally { - globalAny.$injector = previousInjector; + setGlobalInjector(previousInjector); } }); }); From ec5de904bb0b6c176e42b0ac8f3b88ea8ff7ee0f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 22:42:56 -0300 Subject: [PATCH 10/14] refactor(yok): type the di bridge on IInjector The token container was reachable only through a getter on the concrete Yok class, so every crossing from facade-typed code into the new API needed an any cast - the migration's most important seam was invisible to the type system. IInjector now declares readonly di: Injector. --- lib/common/definitions/yok.d.ts | 8 ++++++++ test/compat/legacy-hooks.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index c430649075..dd1bc529a4 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -1,6 +1,7 @@ import { IDisposable, IDictionary } from "../declarations"; import { ICommand } from "./commands"; import { IKeyCommand, IValidKeyName } from "./key-commands"; +import { Injector } from "../di/injector"; /** * The legacy injector facade surface. Every member is individually @@ -9,6 +10,13 @@ import { IKeyCommand, IValidKeyName } from "./key-commands"; * completes. */ interface IInjector extends IDisposable { + /** + * The token-based container backing this facade — the bridge to the + * new-style API. Registrations and lookups by token go here; code already + * running in an injection context should prefer inject(Injector). + */ + readonly di: Injector; + /** * @deprecated Use provideLazy() from lib/common/di — the same deferred * loading, token-based. diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index eb4c565384..6274d2ca89 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -283,7 +283,7 @@ describe("legacy hook contract", () => { }); assert.strictEqual(capture.logger, testInjector.resolve("logger")); - assert.strictEqual(capture.container, (testInjector).di); + assert.strictEqual(capture.container, testInjector.di); assert.strictEqual(capture.hookArgs, payload); }); From 81f542ab265273c087c09ccd019249fd25367d88 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:08:53 -0300 Subject: [PATCH 11/14] refactor(yok)!: the facade extends Injector instead of wrapping one Yok is now an Injector - class Yok extends Injector - so the new API works on the facade directly (get, register with Providers, createChild, runInInjectionContext($injector, ...)) and inject(Injector) inside legacy-constructed classes returns the facade itself instead of a second, inner container identity. The di bridge is gone. register() dispatches by argument shape: a string first argument is the legacy name-based form, anything else is a Provider. IInjector now extends the Injector class type, which constrains implementers to the real class hierarchy (Yok and its subclasses) - intentional, since the interface only ever described Yok. --- dependency-injection.md | 12 +++-- lib/common/definitions/yok.d.ts | 41 ++++++-------- lib/common/yok.ts | 75 ++++++++++++++------------ test/compat/injector-facade-surface.ts | 19 +++++++ test/compat/legacy-hooks.ts | 2 +- 5 files changed, 83 insertions(+), 66 deletions(-) diff --git a/dependency-injection.md b/dependency-injection.md index fc8a6ae9e5..12b207aaca 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -195,11 +195,13 @@ and resolve late (see above). Working alongside the legacy `$injector` ---------------------------------------- -The `Yok` facade (`global.$injector`) delegates to the token-based container, -exposed as `$injector.di`. Everything is one registry: +The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the +token-based container — so the new API works on it directly: ```ts -$injector.resolve("doctorService") === $injector.di.get(DoctorService); // true +$injector.resolve("doctorService") === $injector.get(DoctorService); // true +$injector.register(provide(DoctorService, DoctorServiceImpl)); +runInInjectionContext($injector, () => inject(DoctorService)); ``` - Legacy string names are permanent: a contract's token name is its interop @@ -244,8 +246,8 @@ The first tranche, growing as services migrate: Legacy → new quick reference ---------------------------- -`di` below is the token-based container backing the facade (`$injector.di`), -or any `Injector` you hold directly. +`di` below is any `Injector` you hold — including `$injector` itself, which +extends `Injector`. | Legacy (`$injector`) | New | |---|---| diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index dd1bc529a4..045a7e6401 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -1,30 +1,22 @@ -import { IDisposable, IDictionary } from "../declarations"; +import { IDictionary } from "../declarations"; import { ICommand } from "./commands"; import { IKeyCommand, IValidKeyName } from "./key-commands"; import { Injector } from "../di/injector"; +import { Provider } from "../di/providers"; /** - * The legacy injector facade surface. Every member is individually - * @deprecated in favor of the token-based container in lib/common/di; - * the interface itself survives until the hook/extension deprecation - * completes. + * The legacy injector facade surface. It extends the token-based `Injector` — + * the facade IS an injector — and adds the legacy subsystems, whose members + * are individually @deprecated. Only the `Yok` class hierarchy implements + * this; the interface survives until the hook/extension deprecation completes. */ -interface IInjector extends IDisposable { +interface IInjector extends Injector { /** - * The token-based container backing this facade — the bridge to the - * new-style API. Registrations and lookups by token go here; code already - * running in an injection context should prefer inject(Injector). - */ - readonly di: Injector; - - /** - * @deprecated Use provideLazy() from lib/common/di — the same deferred - * loading, token-based. + * @deprecated Use provideLazy() — the same deferred loading, token-based. */ require(name: string, file: string): void; /** - * @deprecated Use provideLazy() from lib/common/di — the same deferred - * loading, token-based. + * @deprecated Use provideLazy() — the same deferred loading, token-based. */ require(names: string[], file: string): void; /** @@ -52,23 +44,21 @@ interface IInjector extends IDisposable { /** * Resolves an implementation by constructor function. * The injector will create new instances for every call. - * @deprecated Use Injector.createInstance from lib/common/di. + * @deprecated Use Injector.createInstance. */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): any; /** - * @deprecated Use Injector.createInstance from lib/common/di. + * @deprecated Use Injector.createInstance. */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): T; /** * Resolves an implementation by name. * The injector will create only one instance per name and return the same instance on subsequent calls. - * @deprecated Use inject(Token) in an injection context, or Injector.get - * from lib/common/di. + * @deprecated Use inject(Token) in an injection context, or Injector.get. */ resolve(name: string, ctorArguments?: IDictionary): any; /** - * @deprecated Use inject(Token) in an injection context, or Injector.get - * from lib/common/di. + * @deprecated Use inject(Token) in an injection context, or Injector.get. */ resolve(name: string, ctorArguments?: IDictionary): T; @@ -81,10 +71,11 @@ interface IInjector extends IDisposable { */ resolveKeyCommand(key: string): IKeyCommand; /** - * @deprecated Use provide() / Injector.register from lib/common/di; a - * contract's token name keeps string spellings resolvable. + * @deprecated Legacy name-based registration. Use the Provider overload or + * provide(); a contract's token name keeps string spellings resolvable. */ register(name: string, resolver: any, shared?: boolean): void; + register(providers: Provider | Provider[]): void; /** * @deprecated Slated for replacement by defineCommand and manifest-declared * commands. diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 7c94b2a6ba..61c5570631 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -9,6 +9,7 @@ import { IInjector } from "./definitions/yok"; import { ICommandArgument, ICommand } from "./definitions/commands"; import { IKeyCommand, IValidKeyName } from "./definitions/key-commands"; import { Injector } from "./di/injector"; +import type { Provider } from "./di/providers"; /** * The legacy global facade binding. New code should obtain the container via @@ -47,22 +48,20 @@ export interface IDependency { } /** - * The Yok facade: the externally reachable injector surface (every IInjector - * member, the global $injector, subclassability) kept intact while storage and - * resolution live in the token-based `Injector` (lib/common/di). Command - * routing, the key-command namespace, and the public-API builder still live - * here — they are separate subsystems that only share the container. + * The Yok facade IS the token-based `Injector` — it extends it — plus the + * legacy surface: command routing, the key-command namespace, the module + * loader, and the public-API builder. Those subsystems historically shared + * the container object and migrate out separately; until then they live here, + * individually marked @deprecated. */ -export class Yok implements IInjector { +export class Yok extends Injector implements IInjector { /** * @deprecated Escape hatch of the legacy require-time module map. */ public overrideAlreadyRequiredModule: boolean = false; - /** The token-based container backing this facade. */ - private container = new Injector(); - constructor() { + super(); this.register("injector", this); } @@ -76,11 +75,6 @@ export class Yok implements IInjector { private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; private hierarchicalCommands: IDictionary = {}; - /** New-style access to the backing container, for token-based registration. */ - public get di(): Injector { - return this.container; - } - /** * @deprecated Path-based command registration; slated for replacement by * manifest-declared commands. @@ -94,7 +88,7 @@ export class Yok implements IInjector { if (commands.length > 1) { if ( _.startsWith(commands[1], "*") && - this.container.has(this.createCommandName(commands[0])) && + this.has(this.createCommandName(commands[0])) && !this.synthesizedParents.has(commands[0]) ) { throw new Error( @@ -115,7 +109,7 @@ export class Yok implements IInjector { if ( commands.length > 1 && - !this.container.has(this.createCommandName(commands[0])) + !this.has(this.createCommandName(commands[0])) ) { this.require(this.createCommandName(commands[0]), file); if (commands[1] && !commandName.match(/\|\*/)) { @@ -191,7 +185,7 @@ export class Yok implements IInjector { } private resolveInstance(name: string): any { - let classInstance = this.container.peek(name); + let classInstance = this.peek(name); if (!classInstance) { classInstance = this.resolve(name); } @@ -207,11 +201,11 @@ export class Yok implements IInjector { ? relativePath : file; - if (!this.container.has(name) || this.overrideAlreadyRequiredModule) { + if (!this.has(name) || this.overrideAlreadyRequiredModule) { // Yok replaced the whole record on an allowed re-require, dropping any // resolver and cached instances with it — preserved via remove(). - this.container.remove(name); - this.container.register({ + this.remove(name); + this.register({ provide: name, useLazyRequire: () => require(dependencyPath), }); @@ -427,22 +421,33 @@ export class Yok implements IInjector { } /** - * @deprecated Use provide() / Injector.register from lib/common/di (via - * `Yok.di`); a contract's token name keeps string spellings resolvable. + * @deprecated Legacy name-based registration. Use a Provider (the overload + * below) or provide(); a contract's token name keeps string spellings + * resolvable. */ - public register(name: string, resolver: any, shared?: boolean): void { - shared = shared === undefined ? true : shared; + public register(name: string, resolver: any, shared?: boolean): void; + public register(providers: Provider | Provider[]): void; + public register( + nameOrProviders: string | Provider | Provider[], + resolver?: any, + shared?: boolean, + ): void { + if (typeof nameOrProviders !== "string") { + super.register(nameOrProviders); + return; + } + shared = shared === undefined ? true : shared; if (_.isFunction(resolver)) { // Classes and factory functions alike: the legacy provider kind // annotate()s the resolver and calls or news it by casing. - this.container.register({ - provide: name, + super.register({ + provide: nameOrProviders, useLegacyClass: resolver, shared, }); } else { - this.container.register({ provide: name, useValue: resolver, shared }); + super.register({ provide: nameOrProviders, useValue: resolver, shared }); } } @@ -452,7 +457,7 @@ export class Yok implements IInjector { public resolveCommand(name: string): ICommand { let command: ICommand; const commandModuleName = this.createCommandName(name); - if (!this.container.has(commandModuleName)) { + if (!this.has(commandModuleName)) { return null; } command = this.resolve(commandModuleName); @@ -466,7 +471,7 @@ export class Yok implements IInjector { public resolveKeyCommand(name: string): IKeyCommand { let command: IKeyCommand; const commandModuleName = this.createKeyCommandName(name); - if (!this.container.has(commandModuleName)) { + if (!this.has(commandModuleName)) { return null; } @@ -483,9 +488,9 @@ export class Yok implements IInjector { if (_.isFunction(param)) { // By-class resolution is transient and never retained — Yok did not // track these instances for disposal either. - return this.container.createInstance(param, [], ctorArguments); + return this.createInstance(param, [], ctorArguments); } - return this.container.get(param, ctorArguments); + return this.get(param, ctorArguments); } /* Regex to match dynamic calls in the following format: @@ -535,7 +540,7 @@ export class Yok implements IInjector { * and help, so the `|` encoding is user-visible. */ public getRegisteredCommandsNames(includeDev: boolean): string[] { - const commandsNames = this.container.getRegisteredNames( + const commandsNames = this.getRegisteredNames( `${this.COMMANDS_NAMESPACE}.`, ); let commands = _.map(commandsNames, (commandName: string) => @@ -551,7 +556,7 @@ export class Yok implements IInjector { * @deprecated Legacy command-registry enumeration. */ public getRegisteredKeyCommandsNames(): string[] { - const commandsNames = this.container.getRegisteredNames( + const commandsNames = this.getRegisteredNames( `${this.KEY_COMMANDS_NAMESPACE}.`, ); const commands = _.map(commandsNames, (commandName: string) => @@ -579,8 +584,8 @@ export class Yok implements IInjector { * @deprecated Delegates to Injector.dispose (reverse instantiation order); * new code disposes the di container directly. */ - public dispose(): void { - this.container.dispose([this]); + public dispose(exclude: any[] = []): void { + super.dispose([this, ...exclude]); } } diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index 935709f246..e10bba8d77 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -1,5 +1,6 @@ import { assert } from "chai"; import { Yok } from "../../lib/common/yok"; +import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; // Pins the externally reachable injector surface: every IInjector member // (lib/common/definitions/yok.d.ts) plus dispose, subclassability, the @@ -65,6 +66,24 @@ describe("injector facade surface", () => { } }); + it("IS an Injector: instanceof holds and the new API works on the facade directly", () => { + const inj = new Yok(); + assert.instanceOf(inj, Injector); + + // Provider-form registration dispatches to the container... + inj.register({ provide: "viaProvider", useValue: { tag: 1 } }); + assert.equal(inj.get("viaProvider").tag, 1); + // ...while string-form registration keeps legacy semantics. + inj.register("viaLegacy", { tag: 2 }); + assert.equal(inj.resolve("viaLegacy").tag, 2); + assert.strictEqual(inj.get("viaLegacy"), inj.resolve("viaLegacy")); + + // One identity: the injection context IS the facade. + runInInjectionContext(inj, () => { + assert.strictEqual(inject(Injector), inj); + }); + }); + it("calls lowercase/anonymous resolvers as factories instead of new-ing them", () => { const inj = new Yok(); inj.register("factoryMade", function () { diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index 6274d2ca89..ed8c1d9f66 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -283,7 +283,7 @@ describe("legacy hook contract", () => { }); assert.strictEqual(capture.logger, testInjector.resolve("logger")); - assert.strictEqual(capture.container, testInjector.di); + assert.strictEqual(capture.container, testInjector); assert.strictEqual(capture.hookArgs, payload); }); From da89071d23b81ace521b3d7ca581ca74d60f3db1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:19:07 -0300 Subject: [PATCH 12/14] refactor(yok): split the facade surface into subsystem contracts IInjector recomposes from per-subsystem faces - CommandRegistry, KeyCommandRegistry, ModuleRegistry, PublicApiBuilder - each an @Contract token the facade registers itself under. One object still implements everything until the subsystems are physically extracted; extraction then becomes a provider swap for the face's token instead of a consumer migration. Consumers can depend on the narrow face they actually use, and deprecation becomes per-face instead of a flat everything-is-legacy. The contracts are internal (lib/common/contracts) and deliberately not re-exported from nativescript/contracts; promoting one is a per-contract decision. --- lib/common/contracts/command-registry.ts | 31 +++++ lib/common/contracts/index.ts | 10 ++ lib/common/contracts/key-command-registry.ts | 15 +++ lib/common/contracts/module-registry.ts | 13 +++ lib/common/contracts/public-api-builder.ts | 13 +++ lib/common/definitions/yok.d.ts | 112 ++----------------- lib/common/yok.ts | 15 +++ test/compat/injector-facade-surface.ts | 24 ++++ 8 files changed, 132 insertions(+), 101 deletions(-) create mode 100644 lib/common/contracts/command-registry.ts create mode 100644 lib/common/contracts/index.ts create mode 100644 lib/common/contracts/key-command-registry.ts create mode 100644 lib/common/contracts/module-registry.ts create mode 100644 lib/common/contracts/public-api-builder.ts diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts new file mode 100644 index 0000000000..25ea0c8702 --- /dev/null +++ b/lib/common/contracts/command-registry.ts @@ -0,0 +1,31 @@ +import { Contract } from "../di/contract"; +import type { ICommand } from "../definitions/commands"; + +/** + * The command-registry face of the injector facade. Transitional contract: it + * mirrors what consumers call today, so that extracting the registry from the + * facade later is a provider swap for this token, not a consumer migration. + * Members slated for replacement keep their deprecation markers. + */ +@Contract({ name: "commandRegistry" }) +export abstract class CommandRegistry { + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ + abstract requireCommand(names: string | string[], file: string): void; + abstract registerCommand(names: string | string[], resolver: any): void; + abstract resolveCommand(name: string): ICommand; + abstract getRegisteredCommandsNames(includeDev: boolean): string[]; + abstract getChildrenCommandsNames(commandName: string): string[]; + abstract buildHierarchicalCommand( + parentCommandName: string, + commandLineArguments: string[], + ): any; + /** Side-effecting: fails with help output on a bad subcommand. */ + abstract isValidHierarchicalCommand( + commandName: string, + commandArguments: string[], + ): Promise; + abstract isDefaultCommand(commandName: string): boolean; +} diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts new file mode 100644 index 0000000000..3e0dafcdee --- /dev/null +++ b/lib/common/contracts/index.ts @@ -0,0 +1,10 @@ +// Internal subsystem contracts of the injector facade. Deliberately NOT +// re-exported from nativescript/contracts: promoting one to the public +// surface is a one-line decision that should be made per contract, not by +// default. Each token resolves to the facade itself until its subsystem is +// physically extracted — at which point the provider is swapped and consumers +// keep working unchanged. +export { CommandRegistry } from "./command-registry"; +export { KeyCommandRegistry } from "./key-command-registry"; +export { ModuleRegistry } from "./module-registry"; +export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/contracts/key-command-registry.ts b/lib/common/contracts/key-command-registry.ts new file mode 100644 index 0000000000..f16e1ee97d --- /dev/null +++ b/lib/common/contracts/key-command-registry.ts @@ -0,0 +1,15 @@ +import { Contract } from "../di/contract"; +import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands"; + +/** + * The key-command face of the injector facade (the `keyCommands.` namespace). + * Kept separate from CommandRegistry because the two registries are redesigned + * on different tracks. + */ +@Contract({ name: "keyCommandRegistry" }) +export abstract class KeyCommandRegistry { + abstract requireKeyCommand(name: IValidKeyName, file: string): void; + abstract registerKeyCommand(name: IValidKeyName, resolver: any): void; + abstract resolveKeyCommand(name: string): IKeyCommand; + abstract getRegisteredKeyCommandsNames(): string[]; +} diff --git a/lib/common/contracts/module-registry.ts b/lib/common/contracts/module-registry.ts new file mode 100644 index 0000000000..2ebca751ab --- /dev/null +++ b/lib/common/contracts/module-registry.ts @@ -0,0 +1,13 @@ +import { Contract } from "../di/contract"; + +/** + * @deprecated The lazy module-loader face of the injector facade — the + * require-time path map. Wholly replaced by provideLazy(); this contract + * exists so its remaining consumers are typed against exactly what they use + * until the bootstrap migrates. + */ +@Contract({ name: "moduleRegistry" }) +export abstract class ModuleRegistry { + abstract require(names: string | string[], file: string): void; + abstract overrideAlreadyRequiredModule: boolean; +} diff --git a/lib/common/contracts/public-api-builder.ts b/lib/common/contracts/public-api-builder.ts new file mode 100644 index 0000000000..6ddb51cc53 --- /dev/null +++ b/lib/common/contracts/public-api-builder.ts @@ -0,0 +1,13 @@ +import { Contract } from "../di/contract"; + +/** + * The public-API-builder face of the injector facade: the machinery behind + * `require('nativescript')`. Bound by the compatibility constraints of the + * published library surface; do not add new entries through it. + */ +@Contract({ name: "publicApiBuilder" }) +export abstract class PublicApiBuilder { + abstract requirePublic(names: string | string[], file: string): void; + abstract requirePublicClass(names: string | string[], file: string): void; + abstract publicApi: any; +} diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index 045a7e6401..89c544444b 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -1,8 +1,10 @@ import { IDictionary } from "../declarations"; -import { ICommand } from "./commands"; -import { IKeyCommand, IValidKeyName } from "./key-commands"; import { Injector } from "../di/injector"; import { Provider } from "../di/providers"; +import { CommandRegistry } from "../contracts/command-registry"; +import { KeyCommandRegistry } from "../contracts/key-command-registry"; +import { ModuleRegistry } from "../contracts/module-registry"; +import { PublicApiBuilder } from "../contracts/public-api-builder"; /** * The legacy injector facade surface. It extends the token-based `Injector` — @@ -10,37 +12,13 @@ import { Provider } from "../di/providers"; * are individually @deprecated. Only the `Yok` class hierarchy implements * this; the interface survives until the hook/extension deprecation completes. */ -interface IInjector extends Injector { - /** - * @deprecated Use provideLazy() — the same deferred loading, token-based. - */ - require(name: string, file: string): void; - /** - * @deprecated Use provideLazy() — the same deferred loading, token-based. - */ - require(names: string[], file: string): void; - /** - * @deprecated Legacy public-API builder. - */ - requirePublic(names: string | string[], file: string): void; - /** - * @deprecated Legacy public-API builder. - */ - requirePublicClass(names: string | string[], file: string): void; - /** - * @deprecated Path-based command registration; slated for replacement by - * manifest-declared commands. - */ - requireCommand(name: string, file: string): void; - /** - * @deprecated Path-based command registration; slated for replacement by - * manifest-declared commands. - */ - requireCommand(names: string[], file: string): void; - /** - * @deprecated Replaced together with the command registry. - */ - requireKeyCommand(name: IValidKeyName, file: string): void; +interface IInjector + extends + Injector, + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder { /** * Resolves an implementation by constructor function. * The injector will create new instances for every call. @@ -62,43 +40,12 @@ interface IInjector extends Injector { */ resolve(name: string, ctorArguments?: IDictionary): T; - /** - * @deprecated Legacy command-registry lookup. - */ - resolveCommand(name: string): ICommand; - /** - * @deprecated Legacy command-registry lookup. - */ - resolveKeyCommand(key: string): IKeyCommand; /** * @deprecated Legacy name-based registration. Use the Provider overload or * provide(); a contract's token name keeps string spellings resolvable. */ register(name: string, resolver: any, shared?: boolean): void; register(providers: Provider | Provider[]): void; - /** - * @deprecated Slated for replacement by defineCommand and manifest-declared - * commands. - */ - registerCommand(name: string, resolver: any): void; - /** - * @deprecated Slated for replacement by defineCommand and manifest-declared - * commands. - */ - registerCommand(names: string[], resolver: any): void; - /** - * @deprecated Replaced together with the command registry. - */ - registerKeyCommand(key: IValidKeyName, resolver: any): void; - /** - * @deprecated Legacy command-registry enumeration; feeds shell - * autocompletion and help. - */ - getRegisteredCommandsNames(includeDev: boolean): string[]; - /** - * @deprecated Legacy command-registry enumeration. - */ - getRegisteredKeyCommandsNames(): string[]; /** * @deprecated String-reflective help templating; removable only together * with the help-template pipeline. @@ -108,43 +55,6 @@ interface IInjector extends Injector { * @deprecated See dynamicCallRegex. */ dynamicCall(call: string, args?: any[]): Promise; - /** - * @deprecated Legacy command-registry routing. - */ - isDefaultCommand(commandName: string): boolean; - /** - * @deprecated Legacy command-registry routing. - * Side-effecting: fails with help output on a bad subcommand. - */ - isValidHierarchicalCommand( - commandName: string, - commandArguments: string[], - ): Promise; - /** - * @deprecated Legacy command-registry routing. - */ - getChildrenCommandsNames(commandName: string): string[]; - /** - * @deprecated Hierarchical-routing internals of the legacy command - * registry. - */ - buildHierarchicalCommand( - parentCommandName: string, - commandLineArguments: string[], - ): any; - /** - * @deprecated Backing store of the require('nativescript') surface. - * Do not add new entries through it. - */ - publicApi: any; - - /** - * Defines if it's allowed to override already required module. - * This can be used in order to allow redefinition of modules, for example $logger can be replaced by a plugin. - * Default value is false. - * @deprecated Escape hatch of the legacy require-time module map. - */ - overrideAlreadyRequiredModule: boolean; } /** diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 61c5570631..6ef8acd88b 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -10,6 +10,12 @@ import { ICommandArgument, ICommand } from "./definitions/commands"; import { IKeyCommand, IValidKeyName } from "./definitions/key-commands"; import { Injector } from "./di/injector"; import type { Provider } from "./di/providers"; +import { + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, +} from "./contracts"; /** * The legacy global facade binding. New code should obtain the container via @@ -63,6 +69,15 @@ export class Yok extends Injector implements IInjector { constructor() { super(); this.register("injector", this); + // Each subsystem face resolves to the facade until it is physically + // extracted; extraction then swaps the provider without touching + // consumers of the token. + this.register([ + { provide: CommandRegistry, useValue: this }, + { provide: KeyCommandRegistry, useValue: this }, + { provide: ModuleRegistry, useValue: this }, + { provide: PublicApiBuilder, useValue: this }, + ]); } private COMMANDS_NAMESPACE: string = "commands"; diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index e10bba8d77..13b1206ae1 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -1,6 +1,12 @@ import { assert } from "chai"; import { Yok } from "../../lib/common/yok"; import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; +import { + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, +} from "../../lib/common/contracts"; // Pins the externally reachable injector surface: every IInjector member // (lib/common/definitions/yok.d.ts) plus dispose, subclassability, the @@ -84,6 +90,24 @@ describe("injector facade surface", () => { }); }); + it("registers its subsystem faces as tokens that resolve to the facade", () => { + const inj = new Yok(); + + for (const token of [ + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, + ]) { + assert.strictEqual(inj.get(token), inj); + } + assert.strictEqual(inj.resolve("commandRegistry"), inj); + + runInInjectionContext(inj, () => { + assert.strictEqual(inject(CommandRegistry), inj); + }); + }); + it("calls lowercase/anonymous resolvers as factories instead of new-ing them", () => { const inj = new Yok(); inj.register("factoryMade", function () { From d839373cf5881de642130fc4a17327ce55cd63ed Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:21:55 -0300 Subject: [PATCH 13/14] fix: address review findings on the DI foundation - doctor-service: printWarnings accepts an optional trackResult matching its contracts; runSetupScript returns the setup script result instead of resolving undefined; canExecuteLocalBuild no longer dereferences its optional argument - deprecation tracer: a report dropped for lack of a logger is no longer latched as delivered - it reports once a logger exists - yok: global.$injector is an accessor pair, so a direct third-party assignment stays synchronized with the binding getInjector() reads - docs: note the useValue + shared:false quirk; show where Injector is imported from in the late-lookup example --- dependency-injection.md | 2 +- extending-cli.md | 2 +- lib/common/deprecation.ts | 4 +++- lib/common/yok.ts | 19 ++++++++++++++----- lib/services/doctor-service.ts | 10 +++++++--- test/compat/injector-facade-surface.ts | 15 ++++++++++++++- test/deprecation.ts | 14 ++++++++++++++ 7 files changed, 54 insertions(+), 12 deletions(-) diff --git a/dependency-injection.md b/dependency-injection.md index 12b207aaca..e25e554731 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -123,7 +123,7 @@ Provider kinds: |---|---|---| | Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields | | Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy | -| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance | +| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance. With `shared: false` there is no resolver and `get()` throws — a preserved legacy quirk | | Factory | `{ provide, useFactory }` | called inside an injection context | `shared: false` makes a provider transient: every resolution constructs a fresh diff --git a/extending-cli.md b/extending-cli.md index d24fd55d45..5ace718f18 100644 --- a/extending-cli.md +++ b/extending-cli.md @@ -92,7 +92,7 @@ module.exports = function (hookArgs) { }; ``` -* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)`, then `injector.get(...)` later. +* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later. * `hookArgs` contains the parameters of the CLI function being hooked; its shape depends on the hook point. Declare it only when you need it — a hook may also take no parameters at all. A future typed hook API (`defineHook` with an explicit context object) will replace this parameter; it is the one remaining piece of the legacy convention. * Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service. * Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge. diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts index 6772be9a9e..5913b18d97 100644 --- a/lib/common/deprecation.ts +++ b/lib/common/deprecation.ts @@ -42,12 +42,14 @@ export function reportDeprecation(report: IDeprecationReport): void { if (reported.has(key)) { return; } - reported.add(key); const logger = report.logger || tryResolveGlobalLogger(); if (!logger) { + // Deliberately not latched: a report dropped for lack of a logger must + // still be deliverable once one becomes resolvable. return; } + reported.add(key); if (stage === "warn") { logger.warn(message); diff --git a/lib/common/yok.ts b/lib/common/yok.ts index 6ef8acd88b..bbde36435f 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -604,10 +604,18 @@ export class Yok extends Injector implements IInjector { } } -if (!(global).$injector) { - (global).$injector = new Yok(); - injector = (global).$injector; -} +// The global is the published legacy surface. It is an accessor pair so a +// direct `global.$injector = x` assignment — allowed for third parties — +// stays synchronized with the module binding that getInjector() and internal +// code read; a plain data property would silently fork the two. +injector = (global).$injector || new Yok(); +Object.defineProperty(global, "$injector", { + get: () => injector, + set: (value: IInjector) => { + injector = value; + }, + configurable: true, +}); /** * Accessor for the process-wide facade, for code that cannot receive the @@ -625,6 +633,7 @@ export function getInjector(): IInjector { * the container via inject(Injector) instead of a process-wide global. */ export function setGlobalInjector(inj: IInjector): IInjector { - (global).$injector = injector = inj; + injector = inj; + (global).$injector = inj; return inj; } diff --git a/lib/services/doctor-service.ts b/lib/services/doctor-service.ts index cdac1e0947..4ff1d3e003 100644 --- a/lib/services/doctor-service.ts +++ b/lib/services/doctor-service.ts @@ -79,7 +79,7 @@ export class DoctorServiceImpl ) {} public async printWarnings(configOptions?: { - trackResult: boolean; + trackResult?: boolean; projectDir?: string; runtimeVersion?: string; options?: IOptions; @@ -177,15 +177,16 @@ export class DoctorServiceImpl "Running the setup script to try and automatically configure your environment.", ); + let result: ISpawnResult; if (this.$hostInfo.isDarwin) { - await this.runSetupScriptCore( + result = await this.runSetupScriptCore( DoctorServiceImpl.DarwinSetupScriptLocation, [], ); } if (this.$hostInfo.isWindows) { - await this.runSetupScriptCore( + result = await this.runSetupScriptCore( DoctorServiceImpl.WindowsSetupScriptExecutable, DoctorServiceImpl.WindowsSetupScriptArguments, ); @@ -195,6 +196,8 @@ export class DoctorServiceImpl action: TrackActionNames.RunSetupScript, additionalData: "Finished", }); + + return result; } public async canExecuteLocalBuild(configuration?: { @@ -203,6 +206,7 @@ export class DoctorServiceImpl runtimeVersion?: string; forceCheck?: boolean; }): Promise { + configuration = configuration || {}; await this.$analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.CheckLocalBuildSetup, additionalData: "Starting", diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts index 13b1206ae1..96f4e3c522 100644 --- a/test/compat/injector-facade-surface.ts +++ b/test/compat/injector-facade-surface.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { Yok } from "../../lib/common/yok"; +import { Yok, getInjector } from "../../lib/common/yok"; import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; import { CommandRegistry, @@ -62,6 +62,19 @@ describe("injector facade surface", () => { assert.strictEqual(sub.resolve("injector"), sub); }); + it("keeps getInjector() synchronized with a direct global.$injector assignment", () => { + const previous = getInjector(); + const fresh = new Yok(); + + (global).$injector = fresh; + try { + assert.strictEqual(getInjector(), fresh); + } finally { + (global).$injector = previous; + } + assert.strictEqual(getInjector(), previous); + }); + it("assigns the process-wide global.$injector", () => { assert.isOk((global).$injector); for (const member of FACADE_METHODS) { diff --git a/test/deprecation.ts b/test/deprecation.ts index 91af413cdf..8de2df0592 100644 --- a/test/deprecation.ts +++ b/test/deprecation.ts @@ -84,4 +84,18 @@ describe("deprecation tracer", () => { setGlobalInjector(previousInjector); } }); + + it("still delivers a report that was previously dropped for lack of a logger", () => { + const previousInjector = getInjector(); + setGlobalInjector(new Yok()); + try { + reportDeprecation({ api: "test.redeliver" }); + } finally { + setGlobalInjector(previousInjector); + } + + reportDeprecation({ api: "test.redeliver", logger }); + + assert.include(logger.traceOutput, "test.redeliver"); + }); }); From 235befd2d800771f9dc7e605535fd031281373d2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 29 Jul 2026 23:50:18 -0300 Subject: [PATCH 14/14] feat(di): Angular-shaped InjectOptions on inject() and Injector.get() optional resolves to null instead of throwing for unknown tokens (a found-but-misconfigured provider still throws); skipSelf starts at the parent, escaping a child scope's shadowing entry; self refuses parent fallthrough. self+skipSelf throws. host is deliberately absent - it is an Angular component-tree concept with no analog in this hierarchy. get()'s former second parameter - the legacy per-call ctorArguments bag - had exactly one caller, the facade's own resolve(name, bag). It moves to a protected getWithLegacyArguments channel, so the public get() aligns with Angular's shape today rather than after the legacy paths are deleted. --- dependency-injection.md | 15 ++++++++++ lib/common/di/index.ts | 1 + lib/common/di/inject.ts | 18 ++++++++++-- lib/common/di/injector.ts | 59 +++++++++++++++++++++++++++++++++++--- lib/common/yok.ts | 2 +- lib/contracts/index.ts | 1 + test/di.ts | 60 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 148 insertions(+), 8 deletions(-) diff --git a/dependency-injection.md b/dependency-injection.md index e25e554731..e645ebdf56 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -95,6 +95,21 @@ string name — all three return the same instance. The string forms exist for interoperability with the legacy registry; use the class token whenever one exists. +Both `inject()` and `get()` take Angular-shaped options as their second +argument: + +```ts +inject(DoctorService, { optional: true }); // DoctorService | null — no throw +inject("logger", { skipSelf: true }); // start at the parent: escapes a + // child scope's shadowing entry +inject("options", { self: true }); // this level only — no fallthrough +``` + +`optional` covers not-found only; a found-but-misconfigured provider still +throws. `self` and `skipSelf` cannot be combined. There is deliberately no +`host` option: it is an Angular component-tree concept with no analog in the +CLI's injector hierarchy. + Registering: providers ---------------------- diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts index 6a87afcae9..1ddf6a1dca 100644 --- a/lib/common/di/index.ts +++ b/lib/common/di/index.ts @@ -1,4 +1,5 @@ export { Injector } from "./injector"; +export type { InjectOptions } from "./injector"; export { inject, runInInjectionContext } from "./inject"; export { forwardRef, resolveForwardRef } from "./forward-ref"; export { diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts index cd4ef9c008..aba3377b04 100644 --- a/lib/common/di/inject.ts +++ b/lib/common/di/inject.ts @@ -1,4 +1,4 @@ -import type { Injector } from "./injector"; +import type { Injector, InjectOptions } from "./injector"; import type { ProviderToken } from "./providers"; // Sync-only by design (no AsyncLocalStorage): `current` is restored in a @@ -7,7 +7,19 @@ import type { ProviderToken } from "./providers"; // later lookups. let current: Injector | null = null; -export function inject(token: ProviderToken): T { +export function inject(token: ProviderToken): T; +export function inject( + token: ProviderToken, + options: InjectOptions & { optional: true }, +): T | null; +export function inject( + token: ProviderToken, + options: InjectOptions, +): T; +export function inject( + token: ProviderToken, + options?: InjectOptions, +): T | null { if (!current) { throw new Error( "inject() can only be called from an injection context — a field " + @@ -16,7 +28,7 @@ export function inject(token: ProviderToken): T { "the Injector itself and use injector.get() for late lookups.", ); } - return current.get(token); + return current.get(token, options); } export function runInInjectionContext(injector: Injector, fn: () => T): T { diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts index 81e7015847..55a89e2482 100644 --- a/lib/common/di/injector.ts +++ b/lib/common/di/injector.ts @@ -6,6 +6,18 @@ import type { Provider, ProviderToken, Type } from "./providers"; type TokenKey = string | Function; +export interface InjectOptions { + /** Resolve to null instead of throwing when the token is not registered. */ + optional?: boolean; + /** Resolve at this injector's level only — no parent fallthrough. */ + self?: boolean; + /** + * Start resolution at the parent — escapes a child scope's shadowing + * entry (e.g. a hook payload key that collides with a service name). + */ + skipSelf?: boolean; +} + type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass"; interface IProviderRecord { @@ -56,10 +68,41 @@ export class Injector { */ // T defaults to any so string tokens (which give inference no source and // would otherwise land on unknown) stay ergonomic during the migration. + public get(token: ProviderToken): T; + public get( + token: ProviderToken, + options: InjectOptions & { optional: true }, + ): T | null; + public get(token: ProviderToken, options: InjectOptions): T; public get( token: ProviderToken, + options?: InjectOptions, + ): T | null { + if (options && options.self && options.skipSelf) { + throw new Error("inject options cannot combine self and skipSelf"); + } + token = resolveForwardRef(token); + const found = this.findRecord(token, options); + if (!found) { + if (options && options.optional) { + return null; + } + throw new Error("unable to resolve " + displayNameOf(token)); + } + return found.owner.instantiate(found.record); + } + + /** + * The legacy facade's channel for Yok's `resolve(name, bag)` sites: the + * bag applies to the construction this call itself triggers, with raw + * own-key semantics, and never propagates to nested resolutions. Not part + * of the public API — new code passes per-call providers to + * createInstance instead. + */ + protected getWithLegacyArguments( + token: ProviderToken, ctorArguments?: { [key: string]: any }, - ): T { + ): any { token = resolveForwardRef(token); const found = this.findRecord(token); if (!found) { @@ -237,12 +280,20 @@ export class Injector { return name !== undefined ? this.providers.get(name) : undefined; } + // self/skipSelf apply to the entry level only: the parent walk below is + // always an ordinary full lookup from that injector on. private findRecord( token: ProviderToken, + options?: InjectOptions, ): { record: IProviderRecord; owner: Injector } | undefined { - const local = this.findRecordLocal(token); - if (local) { - return { record: local, owner: this }; + if (!options || !options.skipSelf) { + const local = this.findRecordLocal(token); + if (local) { + return { record: local, owner: this }; + } + if (options && options.self) { + return undefined; + } } return this.parent ? this.parent.findRecord(token) : undefined; } diff --git a/lib/common/yok.ts b/lib/common/yok.ts index bbde36435f..9187a8d030 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -505,7 +505,7 @@ export class Yok extends Injector implements IInjector { // track these instances for disposal either. return this.createInstance(param, [], ctorArguments); } - return this.get(param, ctorArguments); + return this.getWithLegacyArguments(param, ctorArguments); } /* Regex to match dynamic calls in the following format: diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index b7078cfc08..c13a9b9996 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -14,6 +14,7 @@ export type { IContractOptions } from "../common/di/contract"; export { inject, runInInjectionContext } from "../common/di/inject"; export { forwardRef, resolveForwardRef } from "../common/di/forward-ref"; export { Injector } from "../common/di/injector"; +export type { InjectOptions } from "../common/di/injector"; export { provide, provideLazy } from "../common/di/providers"; export type { Provider, diff --git a/test/di.ts b/test/di.ts index 8d45c208b6..5739a52b7c 100644 --- a/test/di.ts +++ b/test/di.ts @@ -187,6 +187,66 @@ describe("di: forwardRef", () => { }); }); +describe("di: inject options", () => { + it("optional resolves to null for an unknown token, and normally for a known one", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + assert.isNull(injector.get("nothing-here", { optional: true })); + assert.instanceOf(injector.get(Greeter, { optional: true }), GreeterImpl); + + runInInjectionContext(injector, () => { + assert.isNull(inject("nothing-here", { optional: true })); + }); + }); + + it("optional does not swallow a found-but-misconfigured record", () => { + const injector = new Injector([ + { provide: "brokenLiteral", useValue: {}, shared: false }, + ]); + + assert.throws( + () => injector.get("brokenLiteral", { optional: true }), + /no resolver registered/, + ); + }); + + it("skipSelf escapes a child scope's shadowing entry", () => { + const root = new Injector([ + { provide: "logger", useValue: { from: "root" } }, + ]); + const child = root.createChild([ + { provide: "logger", useValue: { from: "payload" } }, + ]); + + assert.equal(child.get("logger").from, "payload"); + assert.equal(child.get("logger", { skipSelf: true }).from, "root"); + // On the root there is no parent to skip to. + assert.isNull(root.get("logger", { skipSelf: true, optional: true })); + }); + + it("self refuses parent fallthrough", () => { + const root = new Injector([{ provide: "rootOnly", useValue: { tag: 1 } }]); + const child = root.createChild([ + { provide: "childOnly", useValue: { tag: 2 } }, + ]); + + assert.equal(child.get("childOnly", { self: true }).tag, 2); + assert.throws( + () => child.get("rootOnly", { self: true }), + /unable to resolve/, + ); + assert.isNull(child.get("rootOnly", { self: true, optional: true })); + }); + + it("rejects combining self and skipSelf", () => { + const injector = new Injector(); + assert.throws( + () => injector.get("anything", { self: true, skipSelf: true }), + /cannot combine self and skipSelf/, + ); + }); +}); + describe("di: cycles", () => { it("reports the full resolution path", () => { class CycleA {