diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 298f1fb6..4769d465 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,30 +21,31 @@ jobs: steps: - name: 'Checkout' - uses: actions/checkout@v4 + uses: actions/checkout@v6 # 1. Setup pnpm - name: 'Install pnpm' - uses: pnpm/action-setup@v4 - with: - version: 9 + uses: pnpm/action-setup@v6 # 2. Setup Node 24 (No pnpm cache here to ensure a fresh fetch) - name: 'Use Node ${{ matrix.node }}' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} # 3. Force Fresh Dependency Resolution # We ignore the lockfile and prefer the latest versions in range. - name: 'Install latest dependencies' - run: pnpm install --no-frozen-lockfile --prefer-latest + run: pnpm install --no-frozen-lockfile # 4. Install Playwright Browsers (Required for Vitest) - name: 'Install Playwright Browsers' run: pnpm exec playwright install --with-deps chromium firefox # 5. Run the Pipeline + - name: 'Build' + run: pnpm build + - name: 'Check Formatting' run: pnpm format @@ -54,8 +55,5 @@ jobs: - name: 'Type Check' run: pnpm type-check - - name: 'Build' - run: pnpm build - - name: 'Test (Node & Headless Browsers)' run: pnpm test diff --git a/CHANGELOG_CLEAN_SLATE.md b/CHANGELOG_CLEAN_SLATE.md index 3a6eb252..4e11225b 100644 --- a/CHANGELOG_CLEAN_SLATE.md +++ b/CHANGELOG_CLEAN_SLATE.md @@ -18,6 +18,7 @@ Temporary changelog for feature branch. * `scripts/fix-esm-imports.js` util for fixing ESM imports. * `toParts()` util function (_support package_). * `isException()` util (_support package_). +* `@inheritMeta()` decorator (_support package_). ### Changed @@ -41,6 +42,7 @@ Temporary changelog for feature branch. * `useCloneable` option has been renamed to `clone` and set to `false` by default, in `MergeOptions` (_contracts and support packages_) * Object `merge()` behaviour changed to correctly deal with concat-spreadable objects and arrays (_support package_). * `set()`, `get()`, `has()`, `forget()` and `isArrayLike()` no longer use lodash (_support package_). +* `ObjectId` properties are now set to be private. **Non-breaking Changes** @@ -49,7 +51,7 @@ Temporary changelog for feature branch. * Optimized performance of reflections utilities (_support package_). * Optimized performance of objects' `hasAll()`, `hasAny()`, `forgetAll()`, `ObjectId`, `isset()` and `populate()` (_support package_). * Improved typing of array `merge()` to work with any amount of arrays (_support package_). -* `ObjectId` properties are now set to be private. +* `getClassPropertyDescriptors()` can now also return property descriptors for static defined properties (_support package_). * The TypeScript source files are now also published, along with appropriate source maps. * dprint is now used for formatting. diff --git a/package.json b/package.json index ca2c98b8..4777bf3c 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,11 @@ "@babel/plugin-proposal-decorators": "^7.29.0", "@eslint/js": "^10.0.1", "@types/luxon": "^3.7.1", - "@types/node": "^25.6.2", - "@vitest/browser": "^4.1.5", - "@vitest/browser-playwright": "^4.1.5", - "@vitest/coverage-istanbul": "^4.1.5", - "@vitest/coverage-v8": "^4.1.5", + "@types/node": "^25.7.0", + "@vitest/browser": "^4.1.6", + "@vitest/browser-playwright": "^4.1.6", + "@vitest/coverage-istanbul": "^4.1.6", + "@vitest/coverage-v8": "^4.1.6", "@vuepress/bundler-vite": "2.0.0-rc.29", "@vuepress/client": "2.0.0-rc.29", "@vuepress/plugin-back-to-top": "2.0.0-rc.128", @@ -47,14 +47,14 @@ "eslint": "^10.3.0", "globals": "^17.6.0", "luxon": "^3.7.2", - "playwright": "^1.59.1", + "playwright": "^1.60.0", "sass": "^1.99.0", "tslib": "^2.8.1", "turbo": "^2.9.12", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.11", - "vitest": "^4.1.5", + "typescript-eslint": "^8.59.3", + "vite": "^8.0.12", + "vitest": "^4.1.6", "vue": "^3.5.34", "vuepress": "2.0.0-rc.29" }, @@ -67,13 +67,13 @@ "--- [ Development & Docs ] ---": "echo", "docs:dev": "vuepress dev docs", "docs:build": "vuepress build docs", - "type-check": "turbo run type-check && tsc -p tsconfig.json --noEmit", "--- [ Quality Control (Lint/Format/Test) ] ---": "echo", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "dprint check", "format:fix": "dprint fmt", "fix:all": "npm run format:fix && npm run lint:fix && npm run fix:imports", + "type-check": "turbo run type-check && tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:watch": "vitest", "--- [ Build & Release ] ---": "echo", @@ -104,5 +104,5 @@ "publish-check": "Simulates a workspace-wide npm publish (dry-run).", "publish": "Full release workflow: sync, build, tag, and publish." }, - "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8" + "packageManager": "pnpm@11.1.1" } diff --git a/packages/contracts/src/support/meta/MemberAddress.ts b/packages/contracts/src/support/meta/MemberAddress.ts new file mode 100644 index 00000000..40f10e5f --- /dev/null +++ b/packages/contracts/src/support/meta/MemberAddress.ts @@ -0,0 +1,51 @@ +import { Key } from '../types.js'; +import OwnerContext from './OwnerContext.js'; + +/** + * Member Address + */ +export default interface MemberAddress { + /** + * Owner Context + * + * @type {OwnerContext | undefined} + */ + ctx: OwnerContext | undefined; + + /** + * Indicates whether the member is static (`true`) or otherwise (`false`) + * + * @type {boolean} + */ + readonly static: boolean; + + /** + * The kind of member + * + * @type {string} + */ + readonly kind: string; + + /** + * Name of the member + * + * @type {string|symbol} + */ + readonly name: string | symbol; + + /** + * The "base" path to where metadata is stored inside a Meta Repository + * + * @type {Key} + */ + readonly basePath: Key; + + /** + * Returns the full path to the given key in a Meta Repository, for the member + * + * @param {Key} [key] + * + * @returns {Key} {@link basePath} if no key provided + */ + path(key?: Key): Key; +} diff --git a/packages/contracts/src/support/meta/OwnerContext.ts b/packages/contracts/src/support/meta/OwnerContext.ts new file mode 100644 index 00000000..84cf9b37 --- /dev/null +++ b/packages/contracts/src/support/meta/OwnerContext.ts @@ -0,0 +1,11 @@ +/** + * Owner Context + */ +export default interface OwnerContext { + /** + * Reference to the owner of a Meta Repository + * + * @type {WeakRef} + */ + readonly ownerRef: WeakRef; +} diff --git a/packages/contracts/src/support/meta/Repository.ts b/packages/contracts/src/support/meta/Repository.ts index a810fc37..987158f7 100644 --- a/packages/contracts/src/support/meta/Repository.ts +++ b/packages/contracts/src/support/meta/Repository.ts @@ -1,8 +1,7 @@ -// @aedart/contracts/support/meta/Repository.js import { Key } from '../types.js'; /** - * Metadata Repository Contract + * Metadata Repository */ export default interface Repository { /** diff --git a/packages/contracts/src/support/meta/index.ts b/packages/contracts/src/support/meta/index.ts index fb71f8e9..b1965c3d 100644 --- a/packages/contracts/src/support/meta/index.ts +++ b/packages/contracts/src/support/meta/index.ts @@ -5,8 +5,10 @@ */ export const SUPPORT_META: unique symbol = Symbol('@aedart/contracts/support/meta'); +import MemberAddress from './MemberAddress.js'; import MetaEntry from './MetaEntry.js'; +import OwnerContext from './OwnerContext.js'; import Repository from './Repository.js'; -export { type MetaEntry, type Repository }; +export { type MemberAddress, type MetaEntry, type OwnerContext, type Repository }; export type * from './types.js'; diff --git a/packages/support/src/meta/Address.ts b/packages/support/src/meta/Address.ts new file mode 100644 index 00000000..667d58ab --- /dev/null +++ b/packages/support/src/meta/Address.ts @@ -0,0 +1,109 @@ +import { type Key } from '@aedart/contracts/support'; +import { type MemberAddress, type OwnerContext } from '@aedart/contracts/support/meta'; +import { toParts } from '../objects/toParts.js'; + +/** + * Member Address + */ +export default class Address implements MemberAddress +{ + /** + * @inheritdoc + */ + ctx: OwnerContext | undefined; + + /** + * @inheritdoc + */ + readonly static: boolean; + + /** + * @inheritdoc + */ + readonly kind: string; + + /** + * @inheritdoc + */ + readonly name: string | symbol; + + /** + * @inheritdoc + */ + readonly basePath: Key; + + /** + * Create a new member address instance + * + * @param {OwnerContext | undefined} ctx + * @param {boolean} isStatic + * @param {string} kind + * @param {string | symbol} name + */ + constructor( + ctx: OwnerContext | undefined, + isStatic: boolean, + kind: string, + name: string | symbol, + ) + { + this.ctx = ctx; + this.static = isStatic; + this.kind = kind; + this.name = name; + this.basePath = this.resolveBasePath(); + } + + /** + * @inheritdoc + */ + path(key?: Key): Key + { + if (key === undefined || key === null) { + return this.basePath; + } + + const base = this.basePath as PropertyKey[]; + const parts = toParts(key); + + if (parts.length === 0) { + return this.basePath; + } + + // Pre-allocate the exact array size needed. + // This avoids V8 inner-loop array resizing and memory reallocation steps. + const output = new Array(base.length + parts.length); + + // Linear copy operations (highly optimized by V8 runtime JIT) + for (let i = 0; i < base.length; ++i) { + output[i] = base[i]; + } + + for (let i = 0; i < parts.length; ++i) { + output[base.length + i] = parts[i]; + } + + return output; + } + + /** + * Resolves the base path + * + * @returns {Key} + * + * @protected + */ + protected resolveBasePath(): Key + { + const kind = this.kind === 'method' ? 'methods' : 'fields'; + + // Direct layout construction avoids slow unshift structural re-indexes + // E.g. [ 'static', 'methods', 'playSound' ], or [ 'methods', 'playSound' ] + const path = this.static + ? ['static', kind, this.name] + : [kind, this.name]; + + // Freezing stops V8 from keeping hidden "growth memory buffers" + return Object.freeze(path) as unknown as Key; + } +} diff --git a/packages/support/src/meta/Metadata.ts b/packages/support/src/meta/Metadata.ts index 2a10ce27..dd83157e 100644 --- a/packages/support/src/meta/Metadata.ts +++ b/packages/support/src/meta/Metadata.ts @@ -3,6 +3,8 @@ import { Key } from '@aedart/contracts/support'; import { toParts } from '../objects/toParts.js'; import { isConstructor } from '../reflections/isConstructor.js'; import { getOrCreateRepository } from './getOrCreateRepository.js'; +import { addressRegistry } from './registries.js'; +import type Resolved from './Resolved.js'; /** * Metadata Helper @@ -23,9 +25,9 @@ export default class Metadata */ static get(target: object, key: Key, defaultValue?: T): T | undefined { - const resolved = this.resolveTarget(target, key); + const { resolvedTarget, resolvedKey } = this.resolveTargetAndKey(target, key); - return getOrCreateRepository(resolved).get(key, defaultValue); + return getOrCreateRepository(resolvedTarget).get(resolvedKey, defaultValue); } /** @@ -38,9 +40,9 @@ export default class Metadata */ static has(target: object, key: Key): boolean { - const resolved = this.resolveTarget(target, key); + const { resolvedTarget, resolvedKey } = this.resolveTargetAndKey(target, key); - return getOrCreateRepository(resolved).has(key); + return getOrCreateRepository(resolvedTarget).has(resolvedKey); } /** @@ -55,6 +57,37 @@ export default class Metadata return getOrCreateRepository(target).all(); } + /** + * Resolve the actual target and key for metadata lookup + * + * @param {object} target + * @param {Key} key + * + * @returns {Resolved} + * + * @protected + */ + protected static resolveTargetAndKey(target: object, key: Key): Resolved + { + let owner = target; + let resolvedKey = key; + + const address = addressRegistry.get(target); + if (address !== undefined) { + const resolvedOwner = address.ctx?.ownerRef?.deref(); + if (resolvedOwner !== undefined) { + owner = resolvedOwner; + } + + resolvedKey = address.path(key); + } + + return { + resolvedTarget: this.resolveTarget(owner, resolvedKey), + resolvedKey, + }; + } + /** * Resolves the actual target for metadata lookup * diff --git a/packages/support/src/meta/OwnerContext.ts b/packages/support/src/meta/OwnerContext.ts new file mode 100644 index 00000000..8d19a137 --- /dev/null +++ b/packages/support/src/meta/OwnerContext.ts @@ -0,0 +1,33 @@ +import { type OwnerContext as OwnerContextContract } from '@aedart/contracts/support/meta'; + +/** + * Owner Context + */ +export default class OwnerContext implements OwnerContextContract +{ + /** + * Reference to the owner of a Meta Repository + * + * @type {WeakRef} + * + * @private + */ + readonly #owner: WeakRef; + + /** + * Create a new owner context + * + * @param {object} owner + */ + constructor(owner: object) + { + this.#owner = new WeakRef(owner); + } + + /** + * @inheritdoc + */ + get ownerRef(): WeakRef { + return this.#owner; + } +} diff --git a/packages/support/src/meta/Resolved.ts b/packages/support/src/meta/Resolved.ts new file mode 100644 index 00000000..cda386a0 --- /dev/null +++ b/packages/support/src/meta/Resolved.ts @@ -0,0 +1,18 @@ +import { Key } from '@aedart/contracts/support'; + +/** + * Resolved target and key + * + * @internal + */ +export default interface Resolved { + /** + * The resolved target + */ + resolvedTarget: object; + + /** + * The resolved + */ + resolvedKey: Key; +} diff --git a/packages/support/src/meta/discoverAndFlush.ts b/packages/support/src/meta/discoverAndFlush.ts index 205f4e63..b4fabd48 100644 --- a/packages/support/src/meta/discoverAndFlush.ts +++ b/packages/support/src/meta/discoverAndFlush.ts @@ -1,7 +1,7 @@ import { ConstructorLike } from '@aedart/contracts'; import { isConstructor } from '../reflections/isConstructor.js'; import { flush } from './flush.js'; -import { FLUSHED_METADATA, MEMBER_TO_METADATA } from './registry.js'; +import { FLUSHED_METADATA, MEMBER_TO_METADATA } from './registries'; /** * Discovers and flushes staged metadata for the given target. diff --git a/packages/support/src/meta/findOrCreateMemberAddress.ts b/packages/support/src/meta/findOrCreateMemberAddress.ts new file mode 100644 index 00000000..89610ad7 --- /dev/null +++ b/packages/support/src/meta/findOrCreateMemberAddress.ts @@ -0,0 +1,34 @@ +import { type MemberAddress, type OwnerContext } from '@aedart/contracts/support/meta'; +import Address from './Address.js'; +import { addressRegistry } from './registries.js'; + +/** + * Find or create "member address" for the given member + * + * @param {unknown} member + * @param {ClassMemberDecoratorContext} context + * @param {OwnerContext | undefined} [owner] + * + * @returns {MemberAddress} + */ +export function findOrCreateMemberAddress( + member: unknown, + context: ClassMemberDecoratorContext, + owner?: OwnerContext, +): MemberAddress +{ + let addressMember = member as object; + + // When accessor is given, attempt to find address via the `get` method. + // See `registerAddress()` for additional details! + if (context.kind === 'accessor') { + addressMember = (member as Record).get; + } + + let address = addressRegistry.get(addressMember); + + // Create member address (possibly without "owner context")... + address ??= new Address(owner, context.static ?? false, context.kind, context.name); + + return address; +} diff --git a/packages/support/src/meta/findRepository.ts b/packages/support/src/meta/findRepository.ts index 8dd3494c..fb9e280b 100644 --- a/packages/support/src/meta/findRepository.ts +++ b/packages/support/src/meta/findRepository.ts @@ -1,6 +1,6 @@ // @aedart/support/meta/findRepository.js import { Repository } from '@aedart/contracts/support/meta'; -import { registry } from './registry.js'; +import { registry } from './registries.js'; /** * Find the nearest parent repository in the prototype chain. diff --git a/packages/support/src/meta/flush.ts b/packages/support/src/meta/flush.ts index f29945ec..b7baadff 100644 --- a/packages/support/src/meta/flush.ts +++ b/packages/support/src/meta/flush.ts @@ -1,16 +1,16 @@ import { ConstructorLike } from '@aedart/contracts'; import { getOrCreateBaseRepository } from './getOrCreateBaseRepository.js'; -import { FLUSHED_METADATA } from './registry.js'; +import { FLUSHED_METADATA } from './registries'; /** * Flush staged metadata into the repositories of the given owner. * * @param {ConstructorLike} owner The Class (constructor) - * @param {Record} staged The metadata object from decorator context + * @param {Record} staged The metadata object from decorator context * * @internal */ -export function flush(owner: ConstructorLike, staged: Record): void +export function flush(owner: ConstructorLike, staged: Record): void { if (FLUSHED_METADATA.has(staged)) { return; @@ -23,7 +23,7 @@ export function flush(owner: ConstructorLike, staged: Record): for (let i = 0, len = keys.length; i < len; i++) { const path = keys[i] as string; - const destination = path.startsWith('static.') + const destination = path.startsWith('static') ? owner : prototype; diff --git a/packages/support/src/meta/getOrCreateBaseRepository.ts b/packages/support/src/meta/getOrCreateBaseRepository.ts index afff9633..28a9ecdc 100644 --- a/packages/support/src/meta/getOrCreateBaseRepository.ts +++ b/packages/support/src/meta/getOrCreateBaseRepository.ts @@ -1,7 +1,7 @@ import { Repository } from '@aedart/contracts/support/meta/index.js'; import { findRepository } from './findRepository.js'; import MetaRepository from './MetaRepository.js'; -import { registry } from './registry.js'; +import { registry } from './registries.js'; /** * Gets the existing repository for a target, or creates a new one diff --git a/packages/support/src/meta/getOrCreateOwnerContext.ts b/packages/support/src/meta/getOrCreateOwnerContext.ts new file mode 100644 index 00000000..976a83ed --- /dev/null +++ b/packages/support/src/meta/getOrCreateOwnerContext.ts @@ -0,0 +1,23 @@ +import { type OwnerContext as OwnerContextContract } from '@aedart/contracts/support/meta'; +import OwnerContext from './OwnerContext.js'; +import { contextCache } from './registries.js'; + +/** + * Returns the "owner context" for owner, or creates a new context if one does not exist + * + * @param {object} owner + * + * @returns {OwnerContext} + */ +export function getOrCreateOwnerContext(owner: object): OwnerContextContract +{ + let ctx = contextCache.get(owner); + + if (!ctx) { + ctx = new OwnerContext(owner); + + contextCache.set(owner, ctx); + } + + return ctx; +} diff --git a/packages/support/src/meta/index.ts b/packages/support/src/meta/index.ts index c4cc4e3c..bf915265 100644 --- a/packages/support/src/meta/index.ts +++ b/packages/support/src/meta/index.ts @@ -1,13 +1,19 @@ +import Address from './Address.js'; import Metadata from './Metadata.js'; import MetaRepository from './MetaRepository.js'; +import OwnerContext from './OwnerContext.js'; -export { Metadata, MetaRepository }; +export { Address, Metadata, MetaRepository, OwnerContext }; // export * from './discoverAndFlush.js'; // internal +export * from './findOrCreateMemberAddress.js'; export * from './findRepository.js'; // export * from './flush.js'; // internal export * from './getOrCreateBaseRepository.js'; +export * from './getOrCreateOwnerContext.js'; export * from './getOrCreateRepository.js'; +export * from './inheritMeta.js'; export * from './meta.js'; -export * from './registry.js'; +export * from './registerAddress.js'; +// export * from './registries.js'; // internal // export * from './resolveKeyValue.js'; // internal diff --git a/packages/support/src/meta/inheritMeta.ts b/packages/support/src/meta/inheritMeta.ts new file mode 100644 index 00000000..9bef4c8f --- /dev/null +++ b/packages/support/src/meta/inheritMeta.ts @@ -0,0 +1,70 @@ +import { type ConstructorLike } from '@aedart/contracts'; +import { getNameOrDesc } from '../reflections/getNameOrDesc.js'; +import { findOrCreateMemberAddress } from './findOrCreateMemberAddress.js'; +import Metadata from './Metadata.js'; +import { registerAddress } from './registerAddress.js'; + +/** + * Inherits metadata for a static overridden class member. + * + * If target does not have metadata, then this decorator does nothing. + * + * @returns {(target: unknown, context: (ClassDecoratorContext | ClassMemberDecoratorContext)) => void} + * + * @throws {TypeError} If target is a class, or if target is not static + */ +export function inheritMeta() +{ + return function( + target: unknown, + context: ClassDecoratorContext | ClassMemberDecoratorContext, + ): void + { + // Abort if decorator used directly on class. + if (context.kind === 'class') { + throw new TypeError( + `Invalid target class "${ + getNameOrDesc(target as ConstructorLike) + } for inheritMeta(). Only static class members can inherit meta!"`, + ); + } + + // Abort if the member is NOT static. + if (!context.static) { + throw new TypeError( + `Invalid target ${context.kind} "${ + String(context.name) + }" for inheritMeta(). Member is not static!`, + ); + } + + // Find or create a member address for the target (without "owner context") + const memberAddress = findOrCreateMemberAddress(target, context); + + // Using context.addInitializer, we determine the "owner" of the taget and attempt + // to find if there is metadata defined for the target, in the inheritance chain. + context.addInitializer(function(this: unknown) + { + const constructor = context.static + ? this as ConstructorLike + : ((this as object).constructor + ?? (Object.getPrototypeOf(this) as object | undefined)?.constructor) as + | ConstructorLike + | undefined; + + // Skip if there isn't a constructor available, or if of "field" kind (taget is `undefined`). + if (!constructor || context.kind === 'field') { + return; + } + + // Skip if there is no metadata defined for the member. + if (!Metadata.has(constructor, memberAddress.basePath)) { + return; + } + + // At this point, we know there is metadata for the member. Therefore, we simply register + // the address in the same way as in the `@meta` decorator. + registerAddress(constructor, target as object, memberAddress); + }); + }; +} diff --git a/packages/support/src/meta/meta.ts b/packages/support/src/meta/meta.ts index 4d9386b1..394249a4 100644 --- a/packages/support/src/meta/meta.ts +++ b/packages/support/src/meta/meta.ts @@ -1,9 +1,13 @@ import { ConstructorLike } from '@aedart/contracts'; import { MetaCallback } from '@aedart/contracts/support/meta/index.js'; import { Key } from '@aedart/contracts/support/types.js'; +import { set } from '../objects/set.js'; +import { isKeyUnsafe } from '../reflections/isKeyUnsafe.js'; +import { findOrCreateMemberAddress } from './findOrCreateMemberAddress.js'; import { flush } from './flush.js'; import { getOrCreateRepository } from './getOrCreateRepository.js'; -import { MEMBER_TO_METADATA } from './registry.js'; +import { registerAddress } from './registerAddress.js'; +import { MEMBER_TO_METADATA } from './registries.js'; import { resolveKeyValue } from './resolveKeyValue.js'; /** @@ -40,22 +44,34 @@ export function meta(keyOrCallback: Key | MetaCallback, value?: unknown) return; } - // 4. If it's a member decorator, stage the metadata - const kind = context.kind === 'method' ? 'methods' : 'fields'; - const prefix = isStatic ? 'static.' : ''; + // 4. Find or create member address (in this case without the "owner context", which is resolved later). + const memberAddress = findOrCreateMemberAddress(target, context); - // E.g. 'static.methods.playSound.volumne', 'fields.id.fetch_url' - const path = `${prefix}${kind}.${String(context.name)}.${String(key)}`; + // Generate a full path (from address) so it can be stored / staged... + let pathParts = memberAddress.path(key) as PropertyKey[]; + if (!Array.isArray(pathParts)) { + pathParts = [pathParts]; + } + + // Fail if any path segment is unsafe. This is needed because `set()` ignores + // any unsafe path. + const partsLen = pathParts.length; + for (let i = 0; i < partsLen; i++) { + if (isKeyUnsafe(pathParts[i])) { + throw new TypeError(`Unsafe metadata key/path detected: ${String(key)}`); + } + } - metadataObj[path] = val; + // 5. If it's a member decorator, stage the metadata + set(metadataObj, pathParts, val); - // 5. Link the member to the metadata object for discovery + // 6. Link the member to the metadata object for discovery // For methods, target is the function. For fields, it's undefined (in 2023-11). if (target !== undefined && target !== null) { MEMBER_TO_METADATA.set(target, metadataObj); } - // 6. Use addInitializer to flush metadata. + // 7. Use addInitializer to flush metadata. // For static members, this runs during class definition. // For instance members, this runs during instantiation. context.addInitializer(function(this: unknown) @@ -67,8 +83,47 @@ export function meta(keyOrCallback: Key | MetaCallback, value?: unknown) | ConstructorLike | undefined; - if (constructor) { - flush(constructor, metadataObj); + // Skip further processing if there isn't a constructor available, or if + // the context.kind is a "field" (target is then `undefined`). + if (!constructor || context.kind === 'field') { + return; + } + + flush(constructor, metadataObj); + + // Save the target (member) address, for the given owner. + // This will enable meta lookups, using the member directly. + registerAddress(constructor, target as object, memberAddress); + + // To ensure that meta is still available via a member reference directly, even when overridden + // in a child class, we register the address again, using member obtained from a property descriptor. + // NOTE: This sadly DOES NOT work for overridden static members (no late static binding of `this`)! + + const descriptor = !isStatic + ? Reflect.getOwnPropertyDescriptor(constructor.prototype, context.name) + : Reflect.getOwnPropertyDescriptor(constructor, context.name); + + if (descriptor === undefined) { + return; + } + + const proto = (() => { + switch (context.kind) { + case 'method': + return descriptor.value as object; + case 'accessor': + return descriptor as object; + case 'setter': + return descriptor.set as object; + case 'getter': + return descriptor.get as object; + default: + return undefined; + } + })(); + + if (proto !== undefined && proto !== target) { + registerAddress(constructor, proto, memberAddress); } }); }; diff --git a/packages/support/src/meta/polyfill.ts b/packages/support/src/meta/polyfill.ts index 8cbf95b2..2d4a8950 100644 --- a/packages/support/src/meta/polyfill.ts +++ b/packages/support/src/meta/polyfill.ts @@ -2,4 +2,4 @@ * Polyfill Symbol.metadata if it does not exist. */ // @ts-expect-error: Symbol.metadata is not yet in the TypeScript Symbol interface -Symbol.metadata ??= Symbol.for('Symbol.metadata'); \ No newline at end of file +Symbol.metadata ??= Symbol.for('Symbol.metadata'); diff --git a/packages/support/src/meta/registerAddress.ts b/packages/support/src/meta/registerAddress.ts new file mode 100644 index 00000000..a4c830ac --- /dev/null +++ b/packages/support/src/meta/registerAddress.ts @@ -0,0 +1,41 @@ +import { type MemberAddress } from '@aedart/contracts/support/meta'; +import { getOrCreateOwnerContext } from './getOrCreateOwnerContext.js'; +import { addressRegistry } from './registries.js'; + +/** + * Register an address for the given member, in the given owner + * + * @param {object} owner + * @param {object} member + * @param {MemberAddress} address + * + * @throws {TypeError} + */ +export function registerAddress( + owner: object, + member: object, + address: MemberAddress, +): void +{ + // "Force" set the context for the address,... + address.ctx = getOrCreateOwnerContext(owner); + + // Abort if member is of "field" kind (in which case the member is `undefined`) + if (address.kind === 'field') { + throw new TypeError( + `Unable to register address for "${ + String(address.name) + }": "field" type is not supported.`, + ); + } + + // Store two entries using same address, when accessor given (for both the `get` and `set` methods) + if (address.kind === 'accessor') { + addressRegistry.set((member as Record).get, address); + addressRegistry.set((member as Record).set, address); + return; + } + + // Store address using the member as provided... + addressRegistry.set(member, address); +} diff --git a/packages/support/src/meta/registry.ts b/packages/support/src/meta/registries.ts similarity index 55% rename from packages/support/src/meta/registry.ts rename to packages/support/src/meta/registries.ts index 4feffece..4211345c 100644 --- a/packages/support/src/meta/registry.ts +++ b/packages/support/src/meta/registries.ts @@ -1,5 +1,9 @@ -import { Repository } from '@aedart/contracts/support/meta/index.js'; -import "./polyfill.js"; +import { + type MemberAddress, + type OwnerContext, + type Repository, +} from '@aedart/contracts/support/meta'; +import './polyfill.js'; /** * The internal registry for all metadata repositories. @@ -27,3 +31,21 @@ export const MEMBER_TO_METADATA = new WeakMap} */ export const FLUSHED_METADATA = new WeakSet(); + +/** + * Cache of Owner Context instances + * + * @internal + * + * @type {WeakMap} + */ +export const contextCache = new WeakMap(); + +/** + * Member Address registry + * + * @internal + * + * @type {WeakMap} + */ +export const addressRegistry = new WeakMap(); diff --git a/packages/support/src/meta/resolveKeyValue.ts b/packages/support/src/meta/resolveKeyValue.ts index 3cd7a517..acc8b0c7 100644 --- a/packages/support/src/meta/resolveKeyValue.ts +++ b/packages/support/src/meta/resolveKeyValue.ts @@ -6,7 +6,7 @@ import { Key } from '@aedart/contracts/support/types.js'; * * @param {Key | MetaCallback} koc Key or Callback * @param {unknown} v Value - * @param {unknown} target The target + * @param {unknown} target The target * @param {ClassDecoratorContext | ClassMemberDecoratorContext} context * * @returns {{ key: Key, val: unknown }} diff --git a/packages/support/src/reflections/getClassPropertyDescriptors.ts b/packages/support/src/reflections/getClassPropertyDescriptors.ts index 8c9d8fd7..ed7fb07f 100644 --- a/packages/support/src/reflections/getClassPropertyDescriptors.ts +++ b/packages/support/src/reflections/getClassPropertyDescriptors.ts @@ -4,10 +4,11 @@ import { populateDescriptors } from './populateDescriptors.js'; import { walkParents } from './walkParents.js'; /** - * Returns all property descriptors defined on the target's prototype chain. + * Returns all property descriptors defined in the target. * * @param {ConstructorLike} target - The target class. - * @param {boolean} [recursive=false] - If `true`, the target's parent prototypes are traversed. + * @param {boolean} [recursive=false] - If `true`, the target's parents are traversed. + * @param {boolean} [fromPrototype=true] - If `true`, property descriptors from the target's prototype is returned. * * @returns {Record} * @@ -18,14 +19,19 @@ import { walkParents } from './walkParents.js'; export function getClassPropertyDescriptors( target: ConstructorLike, recursive = false, + fromPrototype = true, ): Record { assertHasPrototypeProperty(target); const output = Object.create(null) as Record; - // If not recursive, we only care about the immediate prototype. + // If not recursive, we only care about the immediate target. if (!recursive) { - return populateDescriptors(output, target.prototype as object); + const from = fromPrototype + ? target.prototype as object + : target as object; + + return populateDescriptors(output, from); } // To respect the inheritance priority (child overrides parent), we collect @@ -38,8 +44,11 @@ export function getClassPropertyDescriptors( // Since walkParents yields nearest parent first, we iterate the chain in // reverse order to ensure child descriptors win or are merged onto parent descriptors. for (let i = chain.length - 1; i >= 0; i--) { - // populateDescriptors(output, chain[i]); - populateDescriptors(output, chain[i].prototype as object); + const from = fromPrototype + ? chain[i].prototype as object + : chain[i] as object; + + populateDescriptors(output, from); } return output; diff --git a/packages/support/src/reflections/populateDescriptors.ts b/packages/support/src/reflections/populateDescriptors.ts index b5203735..c6ab086c 100644 --- a/packages/support/src/reflections/populateDescriptors.ts +++ b/packages/support/src/reflections/populateDescriptors.ts @@ -1,26 +1,26 @@ import { merge } from '../objects/merge.js'; /** - * Populate descriptors from a given prototype object into the output record. + * Populate descriptors from a given target object into the output record. * * When a key collision occurs, the existing descriptor (child) takes precedence * and is deep-merged with the incoming descriptor (parent). * * @param {Record} output - The accumulator record to populate. - * @param {object} proto - The prototype object to extract descriptors from. + * @param {object} target - The object to extract descriptors from. * * @returns {Record} The populated output record. */ export function populateDescriptors( output: Record, - proto: object, + target: object, ): Record { - const keys = Reflect.ownKeys(proto); + const keys = Reflect.ownKeys(target); const len = keys.length; for (let i = 0; i < len; i++) { const key = keys[i]; - const descriptor = Reflect.getOwnPropertyDescriptor(proto, key); + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); if (descriptor === undefined) { continue; } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7bde30f6..445c1ea3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1,5 @@ -packages: ['packages/*'] +packages: [ 'packages/*' ] +allowBuilds: + '@parcel/watcher': true + dprint: true + esbuild: true diff --git a/tests/browser/support/meta/Address.test.ts b/tests/browser/support/meta/Address.test.ts new file mode 100644 index 00000000..c2f05489 --- /dev/null +++ b/tests/browser/support/meta/Address.test.ts @@ -0,0 +1,48 @@ +import { Address, OwnerContext } from '@aedart/support/meta'; +import { describe, expect, test } from 'vitest'; + +describe('@meta() decorator', () => { + describe('Address', () => { + test('can obtain base path', () => { + const address = new Address(undefined, true, 'method', 'foo'); + + const basePath = address.basePath; + + expect(basePath) + .toEqual(['static', 'methods', 'foo']); + }); + + test('returns base path when no key given', () => { + const address = new Address(undefined, false, 'method', 'foo'); + + const result = address.path(); + + expect(result) + .toEqual(['methods', 'foo']); + }); + + test('returns full path to given key', () => { + const name = Symbol('my_foo_method'); + const key = Symbol('my_secret'); + const address = new Address(undefined, true, 'method', name); + + const result = address.path(key); + + expect(result) + .toEqual(['static', 'methods', name, key]); + }); + + test('can (re)set ctx', () => { + const contextA = new OwnerContext({}); + + const address = new Address(contextA, true, 'method', 'foo'); + expect(address.ctx) + .toEqual(contextA); + + const contextB = new OwnerContext({}); + address.ctx = contextB; + expect(address.ctx) + .toEqual(contextB); + }); + }); +}); diff --git a/tests/browser/support/meta/inheritMeta.test.ts b/tests/browser/support/meta/inheritMeta.test.ts new file mode 100644 index 00000000..ed2191c3 --- /dev/null +++ b/tests/browser/support/meta/inheritMeta.test.ts @@ -0,0 +1,33 @@ +import { inheritMeta } from '@aedart/support/meta'; +import { describe, expect, test } from 'vitest'; + +describe('@inheritMeta() decorator', () => { + test('fails when used on class', () => { + const trigger = () => { + // Not intended to be used on a class... + @inheritMeta() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class A + {} + }; + + expect(trigger).toThrow(); + }); + + test('fails when used on non-static members', () => { + const trigger = () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class A + { + // Not intended to be used on non-static member + @inheritMeta() + foo() + {/* empty */} + } + }; + + expect(trigger).toThrow(); + }); + + // NOTE: See additional tests for @inheritMeta() in `meta-static-members.test.ts`! +}); diff --git a/tests/browser/support/meta/meta-members.test.ts b/tests/browser/support/meta/meta-members.test.ts new file mode 100644 index 00000000..fbf6dfa1 --- /dev/null +++ b/tests/browser/support/meta/meta-members.test.ts @@ -0,0 +1,293 @@ +import { type ConstructorLike } from '@aedart/contracts'; +import { meta, Metadata } from '@aedart/support/meta'; +import { getClassPropertyDescriptors } from '@aedart/support/reflections'; +import { describe, expect, test } from 'vitest'; + +describe('@meta() decorator (instance members)', () => { + class BaseService + { + @meta('wip', 'wap') + myField = 'abc'; + + @meta('fip', 'fup') + accessor value = 42; + + protected _name = 'my-service'; + + @meta('get_name', 'zar') + get name(): string { + return this._name; + } + + @meta('set_name', 'fin') + set name(n: string) { + this._name = n; + } + + @meta('foo', 'bar') + play() + {/* empty */} + } + + class AlphaService extends BaseService + {} + + class BetaService extends AlphaService + {} + + class GammaService extends BetaService + { + override myField = 'cda'; + + override accessor value = 51; + + override get name(): string { + return this._name; + } + + override set name(n: string) { + this._name = n; + } + + override play() + {/* empty */} + } + + test('can get meta using instance member reference', () => { + const instance = new BaseService(); + + // --------------------------------------------------------------------------------------------------- // + // Field: In current version, we cannot obtain meta using property descriptors / reference to a field. + // The only way to do this, is via the class. + expect(Metadata.get(BaseService, 'fields.myField.wip'), 'Unable to get meta for field') + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Accessor: A bit tricky because we have to use property descriptor, using the instance's prototype + + const valueDescriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(instance), + 'value', + )!; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter: Similar to the accessor + + const nameDescriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(instance), + 'name', + )!; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = instance; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); + + test('inherits meta using instance member reference', () => { + const instance = new BetaService(); + + // --------------------------------------------------------------------------------------------------- // + // Field: The same as in previous test. We can only get meta for "field" kind, using the class reference. + expect(Metadata.get(BetaService, 'fields.myField.wip'), 'Unable to get meta for field') + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Get the class descriptors. Unlike previous test, we cannot obtain property descriptors directory from + // the instance's prototype. We need to obtain them from the "base" class, so this util comes in handy. + const descriptors = getClassPropertyDescriptors( + instance.constructor as ConstructorLike, + true, + ); + + // Debug + // console.log('descriptors', descriptors); + + // --------------------------------------------------------------------------------------------------- // + // Accessor + + const valueDescriptor = descriptors.value; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter + + const nameDescriptor = descriptors.name; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = instance; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); + + test('inherits meta using overridden instance member reference', () => { + const instance = new GammaService(); + + // --------------------------------------------------------------------------------------------------- // + // Field: Same as previous tests... + expect(Metadata.get(GammaService, 'fields.myField.wip'), 'Unable to get meta for field') + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Get the class descriptors, just like in the previous test + const overriddenDescriptors = getClassPropertyDescriptors( + instance.constructor as ConstructorLike, + true, + ); + + // Debug + // console.log('descriptors (overridden)', overriddenDescriptors); + + // --------------------------------------------------------------------------------------------------- // + // Accessor + + const valueDescriptor = overriddenDescriptors.value; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter + + const nameDescriptor = overriddenDescriptors.name; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = instance; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); +}); diff --git a/tests/browser/support/meta/meta-security.test.ts b/tests/browser/support/meta/meta-security.test.ts new file mode 100644 index 00000000..b201c7e6 --- /dev/null +++ b/tests/browser/support/meta/meta-security.test.ts @@ -0,0 +1,47 @@ +import { meta } from '@aedart/support/meta'; +import { describe, expect, test } from 'vitest'; + +describe('@meta() security: prototype pollution', () => { + test('immediately prevents pollution via class decorator', () => { + const trigger = () => { + @meta('__proto__.polluted', true) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class Polluter + {} + }; + + // Class decorators run immediately; should throw during definition. + expect(trigger).toThrow(); + }); + + test('prevents pollution via static member decorator', () => { + const trigger = () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class StaticPolluter + { + @meta('constructor.prototype.polluted', true) + static someField = 123; + } + }; + + // Static initializers run during class definition; should throw here. + expect(trigger).toThrow(); + }); + + test('prevents pollution via instance member decorator', () => { + const trigger = () => { + class InstancePolluter + { + @meta('constructor.prototype.polluted', true) + someMethod() + {/* empty */} + } + + // CRITICAL: Must instantiate to trigger the member's addInitializer + new InstancePolluter(); + }; + + // Now that we instantiate, the initializer runs and set() throws. + expect(trigger).toThrow(); + }); +}); diff --git a/tests/browser/support/meta/meta-static-members.test.ts b/tests/browser/support/meta/meta-static-members.test.ts new file mode 100644 index 00000000..8e03a606 --- /dev/null +++ b/tests/browser/support/meta/meta-static-members.test.ts @@ -0,0 +1,396 @@ +import { inheritMeta, meta, Metadata } from '@aedart/support/meta'; +import { getClassPropertyDescriptors } from '@aedart/support/reflections'; +import { describe, expect, test } from 'vitest'; + +describe('@meta() decorator (static members)', () => { + class BaseService + { + @meta('wip', 'wap') + static myField = 'abc'; + + @meta('fip', 'fup') + static accessor value = 42; + + protected static _name = 'my-service'; + + @meta('get_name', 'zar') + static get name(): string { + return this._name; + } + + @meta('set_name', 'fin') + static set name(n: string) { + this._name = n; + } + + @meta('foo', 'bar') + static play() + {/* empty */} + } + + class AlphaService extends BaseService + {} + + class BetaService extends AlphaService + {} + + class GammaService extends BetaService + { + // Note: meta is sadly NOT automatically inherited for overridden static members. + // This is because decorator's context.addInitializer() do not offer a late static + // binding for `this` (this = BaseService in this case). For this reason, the only + // way to obtain inherited metadata via a static member reference, is via the + // @inheritMeta() decorator. + + @inheritMeta() // NOTE: Has NO effect for field type, because "target" is undefined in decorator! + static override myField = 'cda'; + + @inheritMeta() + static override accessor value = 51; + + @inheritMeta() + static override get name(): string { + return this._name; + } + + @inheritMeta() + static override set name(n: string) { + this._name = n; + } + + @inheritMeta() + static override play() + {/* empty */} + } + + class SierraService extends GammaService + {} + + class TangoService extends SierraService + {} + + test('can get meta using static member reference', () => { + // --------------------------------------------------------------------------------------------------- // + // Field: Similar as for "instance" field, we can only obtain a static field's meta using the class and + // full path. + expect( + Metadata.get(BaseService, 'static.fields.myField.wip'), + 'Unable to get meta for field', + ) + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Accessor: Using a property descriptor of the class and the "accessor", then should be possible. + + const valueDescriptor = Object.getOwnPropertyDescriptor( + BaseService, + 'value', + )!; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter: Similar to the accessor + + const nameDescriptor = Object.getOwnPropertyDescriptor( + BaseService, + 'name', + )!; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = BaseService; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); + + test('inherits meta using static member reference', () => { + // --------------------------------------------------------------------------------------------------- // + // Field: The same as in previous test. We can only get meta for "field" kind, using the class reference. + expect( + Metadata.get(BetaService, 'static.fields.myField.wip'), + 'Unable to get meta for field', + ) + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Get the class descriptors (static defined properties) + + const descriptors = getClassPropertyDescriptors( + BetaService, + true, + false, + ); + + // Debug + // console.log('descriptors', descriptors); + + // --------------------------------------------------------------------------------------------------- // + // Accessor + + const valueDescriptor = descriptors.value; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter + + const nameDescriptor = descriptors.name; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = BetaService; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); + + test('inherits meta using overridden static member reference (via @inheritMeta())', () => { + // --------------------------------------------------------------------------------------------------- // + // Field: Same as previous tests... + expect( + Metadata.get(GammaService, 'static.fields.myField.wip'), + 'Unable to get meta for field', + ) + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Get the class descriptors, just like in the previous test + const overriddenDescriptors = getClassPropertyDescriptors( + GammaService, + true, + false, + ); + + // Debug + // console.log('descriptors (overridden)', overriddenDescriptors); + + // --------------------------------------------------------------------------------------------------- // + // Accessor + + const valueDescriptor = overriddenDescriptors.value; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter + + const nameDescriptor = overriddenDescriptors.name; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = GammaService; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); + + test('inherits meta using overridden static member reference (deep inheritance chain)', () => { + // --------------------------------------------------------------------------------------------------- // + // Field: Same as previous tests... + expect( + Metadata.get(TangoService, 'static.fields.myField.wip'), + 'Unable to get meta for field', + ) + .toBe('wap'); + + // --------------------------------------------------------------------------------------------------- // + // Get the class descriptors, just like in the previous test + const overriddenDescriptors = getClassPropertyDescriptors( + TangoService, + true, + false, + ); + + // Debug + // console.log('descriptors (overridden)', overriddenDescriptors); + + // --------------------------------------------------------------------------------------------------- // + // Accessor + + const valueDescriptor = overriddenDescriptors.value; + + expect( + Metadata.has(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (get)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.get as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (get)', + ) + .toBe('fup'); + expect( + Metadata.has(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for accessor (set)', + ) + .toBe(true); + expect( + Metadata.get(valueDescriptor.set as object, 'fip'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for accessor (set)', + ) + .toBe('fup'); + + // --------------------------------------------------------------------------------------------------- // + // Getter / Setter + + const nameDescriptor = overriddenDescriptors.name; + + expect( + Metadata.has(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for getter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'get_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for getter', + ) + .toBe('zar'); + expect( + Metadata.has(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Unable to determine if has meta for setter', + ) + .toBe(true); + expect( + Metadata.get(nameDescriptor.get as object, 'set_name'), // eslint-disable-line @typescript-eslint/unbound-method + 'Incorrect meta value for setter', + ) + .toBe('fin'); + + // --------------------------------------------------------------------------------------------------- // + // Method: This should be straight forward... + + const { play } = TangoService; // eslint-disable-line @typescript-eslint/unbound-method + expect(Metadata.has(play, 'foo'), 'Unable to determine if has meta for instance method') + .toBe(true); + expect(Metadata.get(play, 'foo'), 'Incorrect meta value for instance method') + .toBe('bar'); + }); +}); diff --git a/tests/browser/support/meta/meta.test.ts b/tests/browser/support/meta/meta.test.ts index 56d57d3e..354aebda 100644 --- a/tests/browser/support/meta/meta.test.ts +++ b/tests/browser/support/meta/meta.test.ts @@ -230,48 +230,36 @@ describe('@meta() decorator', () => { expect(Metadata.get(SecureClass, MY_KEY)).toBe('secret-value'); }); - describe('@meta() security: prototype pollution', () => { - test('immediately prevents pollution via class decorator', () => { - const trigger = () => { - @meta('__proto__.polluted', true) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class Polluter - {} - }; - - // Class decorators run immediately; should throw during definition. - expect(trigger).toThrow(); - }); - - test('prevents pollution via static member decorator', () => { - const trigger = () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class StaticPolluter - { - @meta('constructor.prototype.polluted', true) - static someField = 123; - } - }; - - // Static initializers run during class definition; should throw here. - expect(trigger).toThrow(); - }); - - test('prevents pollution via instance member decorator', () => { - const trigger = () => { - class InstancePolluter - { - @meta('constructor.prototype.polluted', true) - someMethod() - {/* empty */} - } - - // CRITICAL: Must instantiate to trigger the member's addInitializer - new InstancePolluter(); - }; - - // Now that we instantiate, the initializer runs and set() throws. - expect(trigger).toThrow(); - }); + test('can use Symbols as metadata keys, for method', () => { + const MY_KEY = Symbol('my_key'); + + class MyClass + { + @meta(MY_KEY, 'secret-value') + myMethod() + {/* empty */} + } + + // Debug + // new MyClass(); + + expect(Metadata.get(MyClass, ['methods', 'myMethod', MY_KEY])).toBe('secret-value'); + }); + + test('can use Symbols as metadata keys, for static method', () => { + const MY_KEY = Symbol('my_key'); + + class MyClass + { + @meta(MY_KEY, 'secret-value') + static myMethod() + {/* empty */} + } + + // Debug + // new MyClass(); + + expect(Metadata.get(MyClass, ['static', 'methods', 'myMethod', MY_KEY])) + .toBe('secret-value'); }); }); diff --git a/tests/browser/support/mixins/decorators/Cached.test.ts b/tests/browser/support/mixins/decorators/Cached.test.ts index 22e99104..72e8831c 100644 --- a/tests/browser/support/mixins/decorators/Cached.test.ts +++ b/tests/browser/support/mixins/decorators/Cached.test.ts @@ -12,10 +12,14 @@ describe('@aedart/support/mixins', () => { const MyMixinA = Cached(Bare((superclass) => { aApplied++; + + // @ts-expect-error Ignore superclass type here. return class extends superclass {}; })); const MyMixinB = Cached(Bare((superclass) => { bApplied++; + + // @ts-expect-error Ignore superclass type here. return class extends superclass {}; })); @@ -23,8 +27,12 @@ describe('@aedart/support/mixins', () => { {} // Notice that the same mixins are applied on the same superclass. + + // @ts-expect-error Ignore MyMixin return type here. class B extends MyMixinA(MyMixinB(A)) {} + + // @ts-expect-error Ignore MyMixin return type here. class C extends MyMixinA(MyMixinB(A)) {} diff --git a/tests/browser/support/mixins/decorators/DeDupe.test.ts b/tests/browser/support/mixins/decorators/DeDupe.test.ts index 948e5311..cda1e3c5 100644 --- a/tests/browser/support/mixins/decorators/DeDupe.test.ts +++ b/tests/browser/support/mixins/decorators/DeDupe.test.ts @@ -8,9 +8,12 @@ describe('@aedart/support/mixins', () => { let applied = 0; const MyMixin = DeDupe(Bare((superclass) => { applied++; + + // @ts-expect-error Ignore superclas type here. return class extends superclass {}; })); + // @ts-expect-error Ignore MyMixin return type here. class A extends MyMixin( MyMixin( MyMixin(class {}), diff --git a/tests/browser/support/mixins/decorators/HasInstance.test.ts b/tests/browser/support/mixins/decorators/HasInstance.test.ts index 022913ae..888a94fa 100644 --- a/tests/browser/support/mixins/decorators/HasInstance.test.ts +++ b/tests/browser/support/mixins/decorators/HasInstance.test.ts @@ -5,10 +5,13 @@ describe('@aedart/support/mixins', () => { describe('decorators', () => { describe('HasInstance', () => { test('respects class inheritance ', () => { + // @ts-expect-error Ignore superclass type here. const MyMixin = HasInstance((superclass) => class extends superclass {}); class A {} + + // @ts-expect-error Ignore MyMixin return type here. class B extends MyMixin(A) {} @@ -25,10 +28,14 @@ describe('@aedart/support/mixins', () => { test('can determine if instance of mixin', () => { // NOTE: The Bare decorator MUST also be applied here, or instance of [mixin] will not work as intended! + + // @ts-expect-error Ignore superclass type here. const MyMixin = HasInstance(Bare((superclass) => class extends superclass {})); class A {} + + // @ts-expect-error Ignore MyMixin return type here. class B extends MyMixin(A) {} diff --git a/tests/browser/support/mixins/decorators/Mixin.test.ts b/tests/browser/support/mixins/decorators/Mixin.test.ts index c885e96f..9d5eb868 100644 --- a/tests/browser/support/mixins/decorators/Mixin.test.ts +++ b/tests/browser/support/mixins/decorators/Mixin.test.ts @@ -8,10 +8,13 @@ describe('@aedart/support/mixins', () => { // It is a bit redundant to test all of "Mixin" decorator's applied decorators. // So here we just ensure that the top-level functionality works as intended. + // @ts-expect-error Ignore superclass type here. const MyMixin = Mixin((superclass) => class extends superclass {}); class A {} + + // @ts-expect-error Ignore MyMixin type here. class B extends MyMixin(A) {} diff --git a/tests/browser/support/reflections/getClassPropertyDescriptors.test.ts b/tests/browser/support/reflections/getClassPropertyDescriptors.test.ts index f6616adb..fecca217 100644 --- a/tests/browser/support/reflections/getClassPropertyDescriptors.test.ts +++ b/tests/browser/support/reflections/getClassPropertyDescriptors.test.ts @@ -18,7 +18,7 @@ describe('@aedart/support/refelctions', () => { .toThrow(TypeError); }); - test('can get property descriptors for class', () => { + test("can get property descriptors for class's prototype", () => { const MY_SYMBOL = Symbol('my_symbol'); class A @@ -72,7 +72,62 @@ describe('@aedart/support/refelctions', () => { } }); - test('can get property descriptors for class recursively', () => { + test('can get static property descriptors for class', () => { + const MY_SYMBOL = Symbol('my_symbol'); + + class A + { + static set name(v) { + /* empty */ + } + static get name() { + return null; + } + static bar() + { + /* empty */ + } + static [MY_SYMBOL]() + { + /* empty */ + } + } + + // -------------------------------------------------------------------------------- // + + const expected = [ + 'prototype', + 'length', + 'name', + 'bar', + MY_SYMBOL, + ]; + + const descriptors = getClassPropertyDescriptors(A, false, false); + + // Debug + // console.log(descriptors); + + for (const key of expected) { + const k = (typeof key == 'symbol') + ? key.toString() + : key; + + expect(Reflect.has(descriptors, key), 'Key ' + k + ' not in descriptors record') + .toBeTruthy(); + + const descriptor = descriptors[key]; + + // Debug + // console.log(key, descriptor); + + expect(descriptor, 'No descriptor returned for ' + k) + .not + .toBeUndefined(); + } + }); + + test("can get property descriptors for class's prototype recursively", () => { const MY_SYMBOL = Symbol('my_symbol'); class A @@ -129,6 +184,67 @@ describe('@aedart/support/refelctions', () => { } }); + test('can get static property descriptors for class recursively', () => { + const MY_SYMBOL = Symbol('my_symbol'); + + class A + { + static set name(v) { + /* empty */ + } + static get name() { + return null; + } + static foo() + { + /* empty */ + } + static [MY_SYMBOL]() + { + /* empty */ + } + } + + class B extends A + { + static set bar(v) { + /* empty */ + } + static get bar() { + return null; + } + } + + // -------------------------------------------------------------------------------- // + + const expected = [ + 'prototype', + 'length', + 'name', + 'foo', + 'bar', + MY_SYMBOL, + ]; + + const descriptors = getClassPropertyDescriptors(B, true, false); + // Debug + // console.log(descriptors); + + for (const key of expected) { + const k = (typeof key == 'symbol') + ? key.toString() + : key; + + expect(Reflect.has(descriptors, key), 'Key ' + k + ' not in descriptors record') + .toBeTruthy(); + + const descriptor = descriptors[key]; + expect(descriptor, 'No descriptor returned for ' + k) + .not + .toBeUndefined(); + } + }); + test('returns top-most property descriptors', () => { const MY_SYMBOL = Symbol('my_symbol'); diff --git a/tests/browser/xyz/decorators.test.ts b/tests/browser/xyz/decorators.test.ts index 2ffc7b17..8d399830 100644 --- a/tests/browser/xyz/decorators.test.ts +++ b/tests/browser/xyz/decorators.test.ts @@ -287,13 +287,13 @@ describe('@aedart/xyz', () => { {/* empty */} @decorator('private call') + // eslint-disable-next-line no-unused-private-class-members #call() - // eslint-disable-line no-unused-private-class-members {/* empty */} @decorator('static private ping') + // eslint-disable-next-line no-unused-private-class-members static #ping() - // eslint-disable-line no-unused-private-class-members {/* empty */} }