From 0fdd5e0d49da866e34c344c9d6b265e1f8d6f7c1 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Wed, 8 Apr 2026 22:00:17 +0200 Subject: [PATCH 01/10] ci: update devcontainer --- .devcontainer/configure-claude.sh | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.devcontainer/configure-claude.sh b/.devcontainer/configure-claude.sh index 315f20e..51ecbbb 100644 --- a/.devcontainer/configure-claude.sh +++ b/.devcontainer/configure-claude.sh @@ -43,16 +43,6 @@ EOF echo "βœ… Created ~/.claude/settings.json with bypass permissions." fi -if [ ! -f ~/.claude.json ]; then - echo "πŸ”§ Creating ~/.claude.json..." - cat > ~/.claude.json << 'EOF' -{ - "autoCompactEnabled": false -} -EOF - echo "βœ… Created ~/.claude.json with autoCompactEnabled set to false." -fi - retry() { local max_attempts="${RETRY_ATTEMPTS:-3}" local delay="${RETRY_DELAY:-1}" @@ -86,6 +76,17 @@ retry claude plugin install git@context-engineering-kit retry claude plugin install ddd@context-engineering-kit retry claude plugin install code-review@context-engineering-kit +# Merge only autoUpdates / autoCompactEnabled so we never replace the whole file (preserves other keys). +CLAUDE_JSON="/home/node/.claude.json" +echo "πŸ”§ Ensuring ${CLAUDE_JSON} has autoUpdates and autoCompactEnabled..." +tmp="$(mktemp)" +if [ -f "$CLAUDE_JSON" ]; then + jq '. + {autoUpdates: true, autoCompactEnabled: false, hasCompletedOnboarding: true}' "$CLAUDE_JSON" >"$tmp" +else + jq -n '{autoUpdates: true, autoCompactEnabled: false, hasCompletedOnboarding: true}' >"$tmp" +fi +mv "$tmp" "$CLAUDE_JSON" +echo "βœ… ${CLAUDE_JSON} updated (autoUpdates=true, autoCompactEnabled=false; other keys preserved)." echo "πŸš€ Claude Code environment ready." echo "Use 'claude' to run Claude Code" \ No newline at end of file From 5cac25443e7c00295dc0d599e057cccd144ece62 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Wed, 8 Apr 2026 22:35:13 +0200 Subject: [PATCH 02/10] spec: add wrap decorator task --- .claude/skills/.gitkeep | 0 .specs/analysis/.gitkeep | 0 .specs/tasks/done/.gitkeep | 0 .specs/tasks/draft/.gitkeep | 0 .../tasks/draft/add-wrap-decorator.feature.md | 42 +++++++++++++++++++ .specs/tasks/in-progress/.gitkeep | 0 .specs/tasks/todo/.gitkeep | 0 7 files changed, 42 insertions(+) create mode 100644 .claude/skills/.gitkeep create mode 100644 .specs/analysis/.gitkeep create mode 100644 .specs/tasks/done/.gitkeep create mode 100644 .specs/tasks/draft/.gitkeep create mode 100644 .specs/tasks/draft/add-wrap-decorator.feature.md create mode 100644 .specs/tasks/in-progress/.gitkeep create mode 100644 .specs/tasks/todo/.gitkeep diff --git a/.claude/skills/.gitkeep b/.claude/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specs/analysis/.gitkeep b/.specs/analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specs/tasks/done/.gitkeep b/.specs/tasks/done/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specs/tasks/draft/.gitkeep b/.specs/tasks/draft/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specs/tasks/draft/add-wrap-decorator.feature.md b/.specs/tasks/draft/add-wrap-decorator.feature.md new file mode 100644 index 0000000..77b602a --- /dev/null +++ b/.specs/tasks/draft/add-wrap-decorator.feature.md @@ -0,0 +1,42 @@ +--- +title: Add wrap decorator +--- + +## Initial User Prompt + +add @Wrap decorator and refactor existing Effect decorator to use it. This is breaking change, can be backward incompatible, will remove EffectOnMethod and EffectOnClass decorators and can remove rest utils, to make code structure better. But commonly used decorators like Effect and hooks should support same interface and provide same behavior. + +### Requrements + +- Refactor existing EffectOnMethod and EffectOnClass decorators to WrapOnMethod and WrapOnClass decorators. +- Refactor existing Effect decorator to Wrap decorator. +- wrap decorator should work with both sync and async methods. +- Write new Effect decorator thath builds on top of Wrap decorator. +- Update @README.md to include new decorator description, also update quick start and how it works sections to use Wrap decorator instead of Effect and include async wrap example in usage section. +- At the end of the task `npm run lint` and `npm run test` should pass! + +#### Wrap decorator + +Wrap decorator should provide easy way to simplify wrap method to function. +```typescript + +export const Log = () => Wrap((method, context: WrapContext) => { + console.log('method called is', context.propertyKey); + return (...args: unknown[]) => { + console.log('method called with', args); + const result = method(...args); + console.log('method returned', result); + return result; + } +}) +``` + +The `WrapContext` esentially `HookContext` but without args and argsObject. The `HookContext` should be based on `WrapContext` and include additionaly args and argsObject. + +#### Effect decorator + +Effect decorator should simply use Wrap decorator but on top add hooks logic. Avoid write new logic, simply move existing logic from @src/effect-on-method.ts that handles args and hooks to @src/effect.decorator.ts + +## Description + +// Will be filled in future stages by business analyst diff --git a/.specs/tasks/in-progress/.gitkeep b/.specs/tasks/in-progress/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specs/tasks/todo/.gitkeep b/.specs/tasks/todo/.gitkeep new file mode 100644 index 0000000..e69de29 From 582f62620be01ab6e6429ab21bd99b4eaccae4b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 23:18:30 +0000 Subject: [PATCH 03/10] feat(wrap): add wrap decorator primitive and refactor effect Introduces the Wrap decorator as the foundational primitive for method/class wrapping, providing full control over execution flow. Effect is now built on top of Wrap. - Add Wrap decorator with WrapContext for method/class decoration - Implement WrapOnMethod and WrapOnClass for wrapping logic - Refactor Effect to use Wrap internally, maintaining API compatibility - Add comprehensive test coverage for Wrap patterns - Update exports and documentation with Wrap usage examples Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/wrap-decorator/SKILL.md | 273 ++++ .../analysis/analysis-add-wrap-decorator.md | 322 +++++ .../tasks/done/add-wrap-decorator.feature.md | 1264 +++++++++++++++++ .../tasks/draft/add-wrap-decorator.feature.md | 42 - README.md | 163 ++- src/effect-on-method.ts | 276 ---- src/effect.decorator.ts | 214 ++- src/hook.types.ts | 44 +- src/index.ts | 4 +- src/{effect-on-class.ts => wrap-on-class.ts} | 51 +- src/wrap-on-method.ts | 117 ++ src/wrap.decorator.ts | 83 ++ tests/Effect.spec.ts | 2 +- tests/EffectOnClass.spec.ts | 482 ------- tests/EffectOnMethod.spec.ts | 1013 ------------- tests/Wrap.spec.ts | 571 ++++++++ tests/WrapOnClass.spec.ts | 649 +++++++++ tests/WrapOnMethod.spec.ts | 503 +++++++ tests/effect-on-method-base.spec.ts | 151 -- tests/hook-types.spec.ts | 199 +++ 20 files changed, 4366 insertions(+), 2057 deletions(-) create mode 100644 .claude/skills/wrap-decorator/SKILL.md create mode 100644 .specs/analysis/analysis-add-wrap-decorator.md create mode 100644 .specs/tasks/done/add-wrap-decorator.feature.md delete mode 100644 .specs/tasks/draft/add-wrap-decorator.feature.md delete mode 100644 src/effect-on-method.ts rename src/{effect-on-class.ts => wrap-on-class.ts} (65%) create mode 100644 src/wrap-on-method.ts create mode 100644 src/wrap.decorator.ts delete mode 100644 tests/EffectOnClass.spec.ts delete mode 100644 tests/EffectOnMethod.spec.ts create mode 100644 tests/Wrap.spec.ts create mode 100644 tests/WrapOnClass.spec.ts create mode 100644 tests/WrapOnMethod.spec.ts delete mode 100644 tests/effect-on-method-base.spec.ts create mode 100644 tests/hook-types.spec.ts diff --git a/.claude/skills/wrap-decorator/SKILL.md b/.claude/skills/wrap-decorator/SKILL.md new file mode 100644 index 0000000..c17215d --- /dev/null +++ b/.claude/skills/wrap-decorator/SKILL.md @@ -0,0 +1,273 @@ +--- +name: Wrap Decorator +description: Design and implementation guide for the Wrap decorator primitive and its relationship to the Effect decorator in the base-decorators library +topics: typescript, decorators, method-wrapping, higher-order-functions, refactoring +created: 2026-04-08 +updated: 2026-04-08 +scratchpad: .specs/scratchpad/690c5e80.md +--- + +# Wrap Decorator + +## Overview + +`Wrap` is a lower-level decorator primitive that exposes raw method wrapping via a higher-order function. Unlike `Effect` (which provides lifecycle hooks), `Wrap` gives the user full control over execution flow by accepting a factory that returns a replacement function. `Effect` is rebuilt on top of `Wrap` by implementing the hooks lifecycle inside the factory. + +--- + +## Key Concepts + +- **WrapContext**: Decoration-time context plus runtime context (target, className) -- everything in `HookContext` except `args` and `argsObject`. +- **WrapFn**: The factory signature `(method, context: WrapContext) => (...args) => unknown`. Called per invocation with a `this`-bound original method. +- **HookContext extends WrapContext**: Interface extension -- `HookContext` adds `args` and `argsObject`. Zero new type assertions needed. +- **WrapOnMethod**: Low-level method decorator that calls the WrapFn factory per invocation. +- **WrapOnClass**: Class decorator that applies WrapOnMethod to all eligible prototype methods. +- **Wrap**: Unified decorator (like `Effect`) that dispatches to `WrapOnClass` or `WrapOnMethod` based on argument count. +- **WRAP_APPLIED_KEY**: Replaces `EFFECT_APPLIED_KEY` as the default sentinel symbol for double-wrap prevention. + +--- + +## Documentation & References + +| Resource | Description | Link | +|----------|-------------|------| +| TypeScript Decorators (legacy) | experimentalDecorators API reference | https://www.typescriptlang.org/docs/handbook/decorators.html | +| base-decorators source | Current implementation to refactor | /workspaces/base-decorators/src/ | +| base-decorators tests | Tests to update/rename | /workspaces/base-decorators/tests/ | +| Type safety rule | Must not increase as-cast count | /workspaces/base-decorators/.claude/rules/preserve-type-safety-during-refactoring.md | + +--- + +## Recommended File Structure After Refactoring + +| Old File | New File | Role | +|----------|----------|------| +| `src/effect-on-method.ts` | `src/wrap-on-method.ts` | Low-level method wrapper (WRAP_APPLIED_KEY, WrapOnMethod, copySymMeta) | +| `src/effect-on-class.ts` | `src/wrap-on-class.ts` | Class decorator using WrapOnMethod | +| `src/effect.decorator.ts` | `src/wrap.decorator.ts` + `src/effect.decorator.ts` | Wrap = primitive; Effect = hooks layer on top | +| `src/hook.types.ts` | `src/hook.types.ts` | Add WrapContext, WrapFn; HookContext extends WrapContext | +| `tests/EffectOnMethod.spec.ts` | `tests/WrapOnMethod.spec.ts` | Renamed tests | +| `tests/EffectOnClass.spec.ts` | `tests/WrapOnClass.spec.ts` | Renamed tests | +| `tests/effect-on-method-base.spec.ts` | `tests/wrap-on-method-base.spec.ts` | Internal helpers moved | + +--- + +## Patterns & Best Practices + +### Pattern 1: WrapContext as base interface + +**When to use**: Always -- this is the foundational type split. +**Trade-offs**: Clean interface extension, no extra type assertions. + +```typescript +export interface WrapContext { + target: object; + propertyKey: string | symbol; + parameterNames: string[]; + className: string; + descriptor: PropertyDescriptor; +} + +export interface HookContext extends WrapContext { + args: unknown[]; + argsObject: HookArgs; +} +``` + +### Pattern 2: Per-invocation factory with bound method + +**When to use**: WrapOnMethod calls the WrapFn factory on each method invocation. +**Trade-offs**: Slight overhead per call; required for runtime `this`/`className` access. + +```typescript +// Inside WrapOnMethod's wrapped function: +const wrapped = function(this: object, ...args: unknown[]) { + const className = (this.constructor as { name: string }).name ?? ''; + const context: WrapContext = { target: this, propertyKey, parameterNames, className, descriptor }; + const boundMethod = originalMethod.bind(this); + const wrappedFn = wrapFn(boundMethod, context); + return wrappedFn(...args); +}; +``` + +### Pattern 3: Effect as Wrap consumer + +**When to use**: Effect delegates entirely to Wrap; hooks logic moves to effect.decorator.ts. +**Trade-offs**: Effect no longer imports from effect-on-method; hooks internals live in effect.decorator.ts. + +```typescript +// In effect.decorator.ts -- Effect's internal wrap factory: +const effectWrapFn = (hooksOrFactory: HooksOrFactory) => + (boundMethod: (...args: unknown[]) => unknown, wrapCtx: WrapContext) => + (...args: unknown[]): unknown => { + const argsObject = buildArgsObject(wrapCtx.parameterNames, args); + const context: HookContext = { ...wrapCtx, args, argsObject }; + const hooks = resolveHooks(hooksOrFactory, context); + const executeMethod = attachHooks(boundMethod, args, context, hooks); + if (hooks.onInvoke) { + const invokeResult = hooks.onInvoke(context); + if (invokeResult instanceof Promise) return invokeResult.then(executeMethod); + } + return executeMethod(); + }; +``` + +### Pattern 4: Exclusion key propagation + +**When to use**: Always -- WrapOnMethod sets the exclusion key; WrapOnClass checks it. +**Trade-offs**: Consistent with existing Effect behavior; WRAP_APPLIED_KEY is the new default. + +```typescript +// WrapOnMethod sets exclusion key at decoration time +setMeta(exclusionKey, true, descriptor); + +// WrapOnClass skips methods already marked +if (getMeta(exclusionKey, descriptor) === true) continue; +``` + +--- + +## User-Facing API + +### Wrap decorator basic usage + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +export const Log = () => Wrap((method, context: WrapContext) => { + console.log('method called is', context.propertyKey); + return (...args: unknown[]) => { + console.log('method called with', args); + const result = method(...args); + console.log('method returned', result); + return result; + }; +}); + +class Calculator { + @Log() + add(a: number, b: number) { + return a + b; + } +} +``` + +### Async Wrap usage + +```typescript +export const AsyncTimer = () => Wrap((method, context: WrapContext) => { + return async (...args: unknown[]) => { + const start = Date.now(); + const result = await method(...args); + console.log(`${String(context.propertyKey)} took ${Date.now() - start}ms`); + return result; + }; +}); +``` + +### Effect continues to work unchanged + +```typescript +import { Effect } from 'base-decorators'; + +class Service { + @Effect({ + onInvoke: ({ args }) => console.log('called with', args), + onReturn: ({ result }) => { console.log('result:', result); return result; }, + }) + compute(x: number) { return x * 2; } +} +``` + +--- + +## Internal Helper Migration + +These functions move FROM `effect-on-method.ts` TO `effect.decorator.ts`: + +| Function | Current Location | New Location | +|----------|-----------------|--------------| +| `buildArgsObject` | `effect-on-method.ts` | `effect.decorator.ts` | +| `attachHooks` | `effect-on-method.ts` | `effect.decorator.ts` | +| `resolveHooks` | `effect-on-method.ts` (private) | `effect.decorator.ts` (private) | +| `chainAsyncHooks` | `effect-on-method.ts` (private) | `effect.decorator.ts` (private) | +| `wrapFunction` | `effect-on-method.ts` | Eliminated -- superseded by WrapOnMethod + effectWrapFn | +| `copySymMeta` | `effect-on-method.ts` (private) | `wrap-on-method.ts` (private) | + +Note: `attachHooks` signature changes -- the `thisArg` parameter is removed since `Wrap` passes a pre-bound method. + +--- + +## Common Pitfalls & Solutions + +| Issue | Impact | Solution | +|-------|--------|----------| +| WrapFn factory called at decoration time instead of per invocation | High | Factory must be inside the `wrapped = function(this)` closure | +| method arg is unbound causing lost this context | Med | Pre-bind method to `this` before passing to factory | +| `as` type assertion count increases during refactoring | Med | Use interface extension (`HookContext extends WrapContext`) not structural casting | +| Tests importing from old file paths break | High | Update all import paths in test files | +| copySymMeta lost during refactoring | High | Keep in wrap-on-method.ts and call it from WrapOnMethod | +| attachHooks previously received thisArg separately | Med | Remove thisArg param; receive pre-bound method from WrapOnMethod | +| effect-on-method-base.spec.ts imports wrapFunction/attachHooks | High | Update test to import from new location or test via Effect | + +--- + +## Export Checklist for index.ts + +After refactoring, `src/index.ts` should export: + +```typescript +// Wrap primitive (new) +export * from './wrap-on-method'; // WrapOnMethod, WRAP_APPLIED_KEY +export * from './wrap-on-class'; // WrapOnClass +export * from './wrap.decorator'; // Wrap + +// Effect (hooks layer on top of Wrap) +export * from './effect.decorator'; // Effect, buildArgsObject (if still public) + +// Types +export type * from './hook.types'; // WrapContext, WrapFn, HookContext, etc. + +// Meta utilities +export * from './set-meta.decorator'; + +// Convenience hook decorators +export * from './on-invoke.hook'; +export * from './on-return.hook'; +export * from './on-error.hook'; +export * from './finally.hook'; +``` + +--- + +## Type Safety Checklist + +Before finalizing implementation, count `as` assertions in refactored files vs. originals: + +- `effect-on-method.ts` baseline: approximately 6-7 assertions (descriptor.value cast, copySymMeta casts) +- These assertions move to `wrap-on-method.ts` (copySymMeta, descriptor.value cast) +- `effect.decorator.ts` gains internal hook logic but HookContext extends WrapContext avoids new casts +- Final count must be equal or fewer than baseline + +--- + +## Sources & Verification + +| Source | Type | Last Verified | +|--------|------|---------------| +| `/workspaces/base-decorators/src/effect-on-method.ts` | Primary (project source) | 2026-04-08 | +| `/workspaces/base-decorators/src/effect-on-class.ts` | Primary (project source) | 2026-04-08 | +| `/workspaces/base-decorators/src/effect.decorator.ts` | Primary (project source) | 2026-04-08 | +| `/workspaces/base-decorators/src/hook.types.ts` | Primary (project source) | 2026-04-08 | +| `/workspaces/base-decorators/tests/*.spec.ts` | Primary (project tests) | 2026-04-08 | +| `.claude/rules/preserve-type-safety-during-refactoring.md` | Project rules | 2026-04-08 | +| Task file: `.specs/tasks/draft/add-wrap-decorator.feature.md` | Task definition | 2026-04-08 | + +--- + +## Changelog + +| Date | Changes | +|------|---------| +| 2026-04-08 | Initial creation for task: Add wrap decorator | diff --git a/.specs/analysis/analysis-add-wrap-decorator.md b/.specs/analysis/analysis-add-wrap-decorator.md new file mode 100644 index 0000000..a1bb991 --- /dev/null +++ b/.specs/analysis/analysis-add-wrap-decorator.md @@ -0,0 +1,322 @@ +--- +title: Codebase Impact Analysis - Add wrap decorator +task_file: .specs/tasks/draft/add-wrap-decorator.feature.md +scratchpad: .specs/scratchpad/a705bfb8.md +created: 2026-04-08 +status: complete +--- + +# Codebase Impact Analysis: Add wrap decorator + +## Summary + +- **Files to Modify**: 4 files (effect.decorator.ts, hook.types.ts, index.ts, README.md) +- **Files to Create**: 5 files (wrap-on-method.ts, wrap-on-class.ts, wrap.decorator.ts, WrapOnMethod.spec.ts, WrapOnClass.spec.ts, Wrap.spec.ts) +- **Files to Delete**: 2 source files (effect-on-method.ts, effect-on-class.ts) + 3 test files replaced +- **Test Files Affected**: 10 files (3 deleted/rewritten, 1 updated, 6 verified) +- **Risk Level**: High (breaking change: removes EffectOnMethod, EffectOnClass, EFFECT_APPLIED_KEY from public API) + +--- + +## Files to be Modified/Created + +### Primary Changes + +``` +src/ +β”œβ”€β”€ wrap-on-method.ts # NEW (internal): Core wrapping primitive, not exported from index.ts +β”‚ # Contains: WRAP_APPLIED_KEY, WrapOnMethod, copySymMeta (private) +β”‚ # Imports: setMeta, SYM_META_PROP (set-meta.decorator); +β”‚ # getParameterNames (getParameterNames); +β”‚ # WrapFn, WrapContext (hook.types) +β”‚ +β”œβ”€β”€ wrap-on-class.ts # NEW (internal): Class-level iteration, not exported from index.ts +β”‚ # Contains: WrapOnClass +β”‚ # Imports: getMeta (set-meta.decorator); WrapFn (hook.types); +β”‚ # WrapOnMethod, WRAP_APPLIED_KEY (wrap-on-method) +β”‚ +β”œβ”€β”€ wrap.decorator.ts # NEW (public): Dispatches to WrapOnClass or WrapOnMethod +β”‚ # Contains: Wrap +β”‚ # Exported from index.ts +β”‚ +β”œβ”€β”€ effect-on-method.ts # DELETE: Logic migrated to wrap-on-method.ts + effect.decorator.ts +β”œβ”€β”€ effect-on-class.ts # DELETE: Logic migrated to wrap-on-class.ts +β”‚ +β”œβ”€β”€ effect.decorator.ts # UPDATE: Rebuild Effect on top of Wrap; receive hook logic here +β”‚ # Gains: buildArgsObject, attachHooks (unchanged signature), +β”‚ # resolveHooks, chainAsyncHooks (all moved from effect-on-method.ts) +β”‚ # Imports: WrapFn, WrapContext, HooksOrFactory from hook.types; +β”‚ # Wrap (or WrapOnMethod/WrapOnClass) from wrap layer +β”‚ +β”œβ”€β”€ hook.types.ts # UPDATE: Add WrapContext and WrapFn; HookContext extends WrapContext +β”‚ +└── index.ts # UPDATE: Remove effect-on-method/effect-on-class exports; + # Add wrap.decorator export; keep all others +``` + +### Test Changes + +``` +tests/ +β”œβ”€β”€ WrapOnMethod.spec.ts # NEW: Covers WrapOnMethod lifecycle, sync/async, metadata, exclusionKey, WRAP_APPLIED_KEY +β”œβ”€β”€ WrapOnClass.spec.ts # NEW: Covers prototype iteration, skip logic, getters, exclusionKey, WRAP_APPLIED_KEY +β”œβ”€β”€ Wrap.spec.ts # NEW: Covers class+method dispatch, user-provided WrapFn shape, sync/async +β”œβ”€β”€ EffectOnMethod.spec.ts # DELETE: No longer valid (EffectOnMethod, EFFECT_APPLIED_KEY removed) +β”œβ”€β”€ EffectOnClass.spec.ts # DELETE: No longer valid (EffectOnClass, EffectOnMethod removed) +β”œβ”€β”€ effect-on-method-base.spec.ts # DELETE/REWRITE: Tests wrapFunction and attachHooks by old import path; +β”‚ # wrapFunction is eliminated; attachHooks moves to effect.decorator.ts +β”œβ”€β”€ Effect.spec.ts # VERIFY: Interface preserved; error message in Effect guard may stay same +β”œβ”€β”€ OnInvokeHook.spec.ts # VERIFY: OnInvokeHook delegates to Effect (unchanged) +β”œβ”€β”€ OnReturnHook.spec.ts # VERIFY: OnReturnHook delegates to Effect (unchanged) +β”œβ”€β”€ OnErrorHook.spec.ts # VERIFY: OnErrorHook delegates to Effect (unchanged) +└── FinallyHook.spec.ts # VERIFY: FinallyHook delegates to Effect (unchanged) +``` + +### Documentation Updates + +``` +README.md # UPDATE: Quick Start uses Wrap; How It Works covers Wrap as primitive; + # add async Wrap usage example; update API reference table +``` + +--- + +## Useful Resources for Implementation + +### Pattern References + +``` +src/ +β”œβ”€β”€ effect.decorator.ts # Dispatcher pattern to replicate for wrap.decorator.ts +β”œβ”€β”€ effect-on-method.ts # wrapFunction (lines 121-155), attachHooks (lines 164-196), +β”‚ # copySymMeta (lines 208-229) β€” all to adapt for wrap layer +└── effect-on-class.ts # Prototype iteration pattern (lines 56-77) for WrapOnClass +``` + +--- + +## Key Interfaces and Contracts + +### Types to Create/Modify in `src/hook.types.ts` + +**Before** (`src/hook.types.ts:12-27`): + +```typescript +interface HookContext { + args: unknown[]; + argsObject: HookArgs; + target: object; + propertyKey: string | symbol; + parameterNames: string[]; + className: string; + descriptor: PropertyDescriptor; +} +``` + +**After** β€” WrapContext as base, HookContext extends it: + +```typescript +// New base (no args/argsObject) +interface WrapContext { + target: object; + propertyKey: string | symbol; + parameterNames: string[]; + className: string; + descriptor: PropertyDescriptor; +} + +// HookContext extends WrapContext β€” same shape as before, zero new `as` casts needed +interface HookContext extends WrapContext { + args: unknown[]; + argsObject: HookArgs; +} + +// New function type for Wrap +type WrapFn = ( + method: (...args: unknown[]) => unknown, + context: WrapContext, +) => (...args: unknown[]) => R; +``` + +### Functions/Methods to Modify + +| Location | Name | Current Signature | Change Required | +|----------|------|-------------------|-----------------| +| `src/effect-on-method.ts:48` | `EffectOnMethod` | `(hooksOrFactory, exclusionKey?) => MethodDecorator` | DELETE β€” replaced by internal `WrapOnMethod` | +| `src/effect-on-class.ts:50` | `EffectOnClass` | `(hooks, exclusionKey?) => ClassDecorator` | DELETE β€” replaced by internal `WrapOnClass` | +| `src/effect.decorator.ts:48` | `Effect` | `(hooks, exclusionKey?) => ClassDecorator & MethodDecorator` | UPDATE β€” rebuild to delegate to Wrap; move hook lifecycle logic here | +| `src/effect-on-method.ts:164` | `attachHooks` | `(originalMethod, thisArg, args, context, hooks) => () => unknown` | MOVE to `effect.decorator.ts` with **unchanged signature** | +| `src/effect-on-method.ts:98` | `buildArgsObject` | `(parameterNames, args) => Record` | MOVE to `effect.decorator.ts` | +| `src/effect-on-method.ts:121` | `wrapFunction` | `(originalMethod, parameterNames, propertyKey, descriptor, hooksOrFactory) => fn` | ELIMINATE β€” superseded by WrapOnMethod internals + effectWrapFn | + +### New Functions to Create + +| Location | Name | Signature | Description | +|----------|------|-----------|-------------| +| `src/wrap-on-method.ts` | `WRAP_APPLIED_KEY` | `unique symbol` | Replaces EFFECT_APPLIED_KEY as default sentinel (internal only) | +| `src/wrap-on-method.ts` | `WrapOnMethod` | `(wrapFn: WrapFn, exclusionKey?) => MethodDecorator` | Core wrapping primitive β€” internal, not in index.ts | +| `src/wrap-on-class.ts` | `WrapOnClass` | `(wrapFn: WrapFn, exclusionKey?) => ClassDecorator` | Iterates prototype, applies WrapOnMethod β€” internal, not in index.ts | +| `src/wrap.decorator.ts` | `Wrap` | `(wrapFn: WrapFn, exclusionKey?) => ClassDecorator & MethodDecorator` | Public dispatcher | + +### Private Helper Migration + +| Function | Source File | Destination | Visibility | +|----------|-------------|-------------|------------| +| `copySymMeta` | `effect-on-method.ts:208` | `wrap-on-method.ts` | private | +| `resolveHooks` | `effect-on-method.ts:237` | `effect.decorator.ts` | private | +| `chainAsyncHooks` | `effect-on-method.ts:253` | `effect.decorator.ts` | private | +| `buildArgsObject` | `effect-on-method.ts:98` | `effect.decorator.ts` | private (internalized) | +| `attachHooks` | `effect-on-method.ts:164` | `effect.decorator.ts` | private (internalized) | + +Note on `attachHooks`: the signature remains unchanged (`originalMethod, thisArg, args, context, hooks`). When called from Effect's internal WrapFn factory, `thisArg` and `target` are the same object because the method is called via `originalMethod.apply(thisArg, args)`. The signature is not simplified β€” the existing logic is moved as-is. + +### Classes/Components Affected + +| Location | Name | Change Required | +|----------|------|-----------------| +| `src/hook.types.ts:12` | `HookContext` | Now extends `WrapContext`; `args` and `argsObject` remain here | +| `src/hook.types.ts` | `WrapContext` | NEW β€” base interface without args/argsObject | +| `src/hook.types.ts` | `WrapFn` | NEW type alias | + +--- + +## Integration Points + +Files that interact with affected code and may need updates: + +| File | Relationship | Impact | Action Needed | +|------|--------------|--------|---------------| +| `src/on-invoke.hook.ts:1` | Imports and calls `Effect` | Low | No change β€” Effect interface is preserved | +| `src/on-return.hook.ts:1` | Imports and calls `Effect` | Low | No change | +| `src/on-error.hook.ts:1` | Imports and calls `Effect` | Low | No change | +| `src/finally.hook.ts:1` | Imports and calls `Effect` | Low | No change | +| `src/getParameterNames.ts` | Required by wrap-on-method.ts | Low | No change β€” just imported from new location | +| `src/index.ts:1-13` | Re-exports all modules | High | Remove effect-on-method and effect-on-class exports; add wrap.decorator export | +| `tests/Effect.spec.ts:3` | Imports `Effect`, `SetMeta`, `getMeta`, `EffectHooks` | Low | VERIFY tests still pass | +| `tests/OnInvokeHook.spec.ts` | Imports `OnInvokeHook` | Low | VERIFY β€” delegates to Effect, unchanged | +| `tests/OnReturnHook.spec.ts` | Imports `OnReturnHook` | Low | VERIFY β€” delegates to Effect, unchanged | +| `tests/OnErrorHook.spec.ts` | Imports `OnErrorHook` | Low | VERIFY β€” delegates to Effect, unchanged | +| `tests/FinallyHook.spec.ts` | Imports `FinallyHook` | Low | VERIFY β€” delegates to Effect, unchanged | +| `tests/SetMeta.spec.ts` | Imports from `set-meta.decorator` | Low | VERIFY β€” module unchanged | +| `tests/getParameterNames.spec.ts` | Imports `getParameterNames` | Low | VERIFY β€” module unchanged | +| `tests/EffectOnMethod.spec.ts:6` | Imports `EffectOnMethod`, `EFFECT_APPLIED_KEY` | High | DELETE β€” replace with WrapOnMethod.spec.ts | +| `tests/EffectOnClass.spec.ts:4` | Imports `EffectOnClass`, `EffectOnMethod`, `EFFECT_APPLIED_KEY` | High | DELETE β€” replace with WrapOnClass.spec.ts | +| `tests/effect-on-method-base.spec.ts:3` | Imports `attachHooks`, `wrapFunction` from effect-on-method | High | DELETE/REWRITE β€” wrapFunction eliminated; attachHooks moves to effect.decorator.ts | + +--- + +## Similar Implementations + +### Pattern 1: Current Effect Dispatcher + +- **Location**: `src/effect.decorator.ts` +- **Why relevant**: `wrap.decorator.ts` follows the identical `propertyKey === undefined` dispatch pattern +- **Key files**: + - `src/effect.decorator.ts:55-72` β€” dispatch logic to replicate, replace `EffectOnClass`/`EffectOnMethod` calls with `WrapOnClass`/`WrapOnMethod` + +### Pattern 2: EffectOnMethod Core Wrapping + +- **Location**: `src/effect-on-method.ts` +- **Why relevant**: WrapOnMethod is the direct successor; copySymMeta, setMeta(exclusionKey) pattern is identical +- **Key files**: + - `src/effect-on-method.ts:208-229` β€” `copySymMeta` to copy verbatim to `wrap-on-method.ts` + - `src/effect-on-method.ts:48-78` β€” decorator factory pattern to replicate in `WrapOnMethod` + +### Pattern 3: EffectOnClass Prototype Iteration + +- **Location**: `src/effect-on-class.ts` +- **Why relevant**: WrapOnClass follows identical `getOwnPropertyNames` + `isPlainMethod` + `shouldSkipMethod` guards +- **Key files**: + - `src/effect-on-class.ts:56-77` β€” iteration body to copy into `WrapOnClass`; guard functions stay private + +--- + +## Test Coverage + +### Existing Tests to Update + +| Test File | Tests Affected | Update Required | +|-----------|----------------|-----------------| +| `tests/EffectOnMethod.spec.ts` | All (1013 lines, 40+ tests) | DELETE and rewrite as `WrapOnMethod.spec.ts` using WrapFn API | +| `tests/EffectOnClass.spec.ts` | All (482 lines) | DELETE and rewrite as `WrapOnClass.spec.ts` | +| `tests/effect-on-method-base.spec.ts` | All β€” imports `wrapFunction`, `attachHooks` by path | REWRITE or DELETE β€” wrapFunction eliminated; test attachHooks via effect.decorator.ts if kept exported, else test via Effect | +| `tests/Effect.spec.ts` | Possibly the "unsupported context" error guard (line 315) | VERIFY β€” message likely stays identical | + +### New Tests Needed + +| Test Type | Location | Coverage Target | +|-----------|----------|-----------------| +| Unit | `tests/WrapOnMethod.spec.ts` | WrapFn called per invocation, WRAP_APPLIED_KEY set, copySymMeta, exclusionKey, sync/async, this binding | +| Unit | `tests/WrapOnClass.spec.ts` | Prototype iteration, skip constructor/getters, WRAP_APPLIED_KEY skip, exclusionKey, double-wrap prevention | +| Unit | `tests/Wrap.spec.ts` | Class+method dispatch, user-provided WrapFn receives bound method + WrapContext, sync/async interop | + +--- + +## Risk Assessment + +### High Risk Areas + +| Area | Risk | Mitigation | +|------|------|------------| +| `this` binding in WrapOnMethod | User's WrapFn must receive a pre-bound method; omitting `originalMethod.bind(this)` causes `this === undefined` inside user's wrapper | Bind explicitly: `const boundMethod = originalMethod.bind(this)` before calling `wrapFn(boundMethod, context)` | +| Effect rebuilt on Wrap | hooks logic (attachHooks, chainAsyncHooks, resolveHooks) must be moved faithfully; any omission breaks all lifecycle behavior | Copy functions exactly; only change import paths; run full test suite | +| Type safety NFR (hard constraint) | `.claude/rules/preserve-type-safety-during-refactoring.md` requires equal or fewer `as` assertions post-refactor. Baseline: **15 `as` keywords** across effect-on-method.ts (10), effect-on-class.ts (2), effect.decorator.ts (3). Refactored code across equivalent new files must not exceed 15 `as` keywords. Using `HookContext extends WrapContext` avoids any new casts. | Count `as` assertions in each new file before completing; use interface extension not structural casting | +| EFFECT_APPLIED_KEY removal | Public export removed; downstream consumers checking EFFECT_APPLIED_KEY by import will break | Acceptable per task (breaking change explicitly stated); no migration guide required per scope | +| effect-on-method-base.spec.ts | Imports `wrapFunction` (eliminated) and `attachHooks` (moved); breaks immediately on rename | Decide whether attachHooks remains exported from effect.decorator.ts for testing or test only via Effect/Wrap | +| WrapOnMethod/WrapOnClass not in index.ts | If accidentally added, exposes internal API contradicting task spec | Add explicit comment in index.ts noting these are intentionally excluded | + +--- + +## index.ts Changes + +The updated `src/index.ts` should be: + +```typescript +// Remove these two lines: +// export * from './effect-on-method'; +// export * from './effect-on-class'; + +// Add this line: +export * from './wrap.decorator'; // exports Wrap + +// Unchanged: +export * from './effect.decorator'; // exports Effect +export type * from './hook.types'; // now also exports WrapContext, WrapFn +export * from './set-meta.decorator'; +export * from './on-invoke.hook'; +export * from './on-return.hook'; +export * from './on-error.hook'; +export * from './finally.hook'; + +// NOT exported (internal): +// wrap-on-method.ts β†’ WrapOnMethod, WRAP_APPLIED_KEY +// wrap-on-class.ts β†’ WrapOnClass +``` + +--- + +## Recommended Exploration + +Before implementation, developer should read: + +1. `/workspaces/base-decorators/src/effect-on-method.ts` β€” Full source to be split; pay attention to `wrapFunction` (lines 121-155) whose logic becomes WrapOnMethod's inner function, `attachHooks` (lines 164-196) which moves unchanged to effect.decorator.ts, and `copySymMeta` (lines 208-229) which moves to wrap-on-method.ts +2. `/workspaces/base-decorators/src/hook.types.ts` β€” Current HookContext (lines 12-27); all derived types (OnReturnContext, OnErrorContext, hook type aliases) remain valid after the WrapContext split because they all extend HookContext +3. `/workspaces/base-decorators/.claude/rules/preserve-type-safety-during-refactoring.md` β€” Hard constraint on `as` assertion count; baseline is 15 across the three source files being refactored + +--- + +## Verification Summary + +| Check | Status | Notes | +|-------|--------|-------| +| All affected files identified | OK | 3 new src (public: 1, internal: 2), 4 modified src, 2 deleted src; 3 new tests, 3 deleted tests, 1 updated test, 6 verified tests | +| WrapOnMethod/WrapOnClass export status | OK | Correctly marked internal β€” NOT in index.ts per task spec line 59 | +| Integration points mapped | OK | 15 integration points documented; all hook test files included as VERIFY entries | +| getParameterNames dependency noted | OK | wrap-on-method.ts imports getParameterNames | +| `as` assertion baseline documented | OK | 15 `as` keywords baseline (effect-on-method.ts: 10, effect-on-class.ts: 2, effect.decorator.ts: 3) | +| attachHooks signature preserved | OK | Move unchanged; do NOT drop thisArg parameter | +| Similar patterns found | OK | 3 patterns: Effect dispatcher, EffectOnMethod wrapping, EffectOnClass iteration | +| Test coverage analyzed | OK | All 10 test files assessed with correct action per file | +| Risks assessed | OK | 6 risk areas including type safety NFR as hard constraint | + +Limitations/Caveats: The exact internal structure of Effect's WrapFn factory (whether `attachHooks` stays exported for tests or is fully private) is a design decision for the implementer. The task says to "simply move existing logic" which suggests keeping signatures intact. diff --git a/.specs/tasks/done/add-wrap-decorator.feature.md b/.specs/tasks/done/add-wrap-decorator.feature.md new file mode 100644 index 0000000..795924c --- /dev/null +++ b/.specs/tasks/done/add-wrap-decorator.feature.md @@ -0,0 +1,1264 @@ +--- +title: Add wrap decorator +--- + +> **Required Skill**: You MUST use and analyse `wrap-decorator` skill before doing any modification to task file or starting implementation of it! +> +> Skill location: `.claude/skills/wrap-decorator/SKILL.md` + +## Initial User Prompt + +add @Wrap decorator and refactor existing Effect decorator to use it. This is breaking change, can be backward incompatible, will remove EffectOnMethod and EffectOnClass decorators and can remove rest utils, to make code structure better. But commonly used decorators like Effect and hooks should support same interface and provide same behavior. + +### Requrements + +- Refactor existing EffectOnMethod and EffectOnClass decorators to WrapOnMethod and WrapOnClass decorators. +- Refactor existing Effect decorator to Wrap decorator. +- wrap decorator should work with both sync and async methods. +- Write new Effect decorator thath builds on top of Wrap decorator. +- Update @README.md to include new decorator description, also update quick start and how it works sections to use Wrap decorator instead of Effect and include async wrap example in usage section. +- At the end of the task `npm run lint` and `npm run test` should pass! + +#### Wrap decorator + +Wrap decorator should provide easy way to simplify wrap method to function. +```typescript + +export const Log = () => Wrap((method, context: WrapContext) => { + console.log('method called is', context.propertyKey); + return (...args: unknown[]) => { + console.log('method called with', args); + const result = method(...args); + console.log('method returned', result); + return result; + } +}) +``` + +The `WrapContext` esentially `HookContext` but without args and argsObject. The `HookContext` should be based on `WrapContext` and include additionaly args and argsObject. + +#### Effect decorator + +Effect decorator should simply use Wrap decorator but on top add hooks logic. Avoid write new logic, simply move existing logic from @src/effect-on-method.ts that handles args and hooks to @src/effect.decorator.ts + +# Description + +This task introduces a new `Wrap` decorator primitive that provides a direct, flexible way to wrap class methods. Unlike the existing `Effect` decorator, which exposes a fixed set of lifecycle hooks (onInvoke, onReturn, onError, finally), `Wrap` gives the decorator author full control over method execution by accepting a wrapper function of the form `(method, context: WrapContext) => (...args) => result`. This is the foundational building block that all other decorators in the library are built upon. + +The architectural change layers the library as: `Wrap` (low-level wrapping) -> `Effect` (hook orchestration built on Wrap) -> convenience hook decorators (OnInvokeHook, OnReturnHook, etc. built on Effect). This separation of concerns makes the library easier to use for simple wrapping scenarios while preserving the full-featured hooks API for complex lifecycle management. Library consumers benefit from a simpler, more natural API for common wrapping patterns, while library maintainers benefit from cleaner separation of concerns in the codebase. + +This is a **breaking change**. `EffectOnMethod` and `EffectOnClass` are removed from the public API, replaced internally by `WrapOnMethod` and `WrapOnClass`. Utility functions like `buildArgsObject`, `wrapFunction`, and `attachHooks` may also be internalized. However, the commonly used public API -- `Effect`, `OnInvokeHook`, `OnReturnHook`, `OnErrorHook`, `FinallyHook`, `SetMeta`, `getMeta`, `setMeta` -- maintains the same interface and identical behavior. + +A new `WrapContext` type is introduced containing decoration-time and runtime context (target, propertyKey, parameterNames, className, descriptor) without per-call argument data (args, argsObject). `HookContext` is redefined to extend `WrapContext` with the additional `args` and `argsObject` fields, preserving its existing shape. + +**Scope**: +- Included: + - New `Wrap` decorator (usable on both classes and individual methods) + - New `WrapContext` type (target, propertyKey, parameterNames, className, descriptor) + - `HookContext` refactored to extend `WrapContext` with args and argsObject + - Internal `WrapOnMethod` and `WrapOnClass` (not publicly exported) + - `Effect` refactored to use `Wrap` internally while preserving identical public interface and behavior + - All hook convenience decorators preserved (OnInvokeHook, OnReturnHook, OnErrorHook, FinallyHook) + - Metadata API preserved (SetMeta, getMeta, setMeta) + - Exclusion key mechanism preserved for both Wrap and Effect + - README updated (Quick Start, How It Works, Wrap documentation, async Wrap example) + - All tests and lint passing +- Excluded: + - New hook types or decorator patterns beyond Wrap + - Migration guide for consumers of removed EffectOnMethod/EffectOnClass exports + - Performance benchmarks or optimization + - Changes to SetMeta/getMeta/setMeta public API + +**User Scenarios**: +1. **Primary Flow**: A decorator author uses `Wrap((method, context) => (...args) => { ... })` to create a method or class decorator that wraps method calls with custom logic, receiving the original method and a WrapContext, returning a function that is called with the actual arguments. +2. **Alternative Flow**: A decorator author uses `Effect({ onInvoke, onReturn, onError, finally })` exactly as before -- Effect internally delegates to Wrap with hook orchestration logic, producing identical behavior for all lifecycle hooks including factory hooks. +3. **Error Handling**: Errors thrown by the original method or the wrapper function propagate naturally to the caller. For Effect-based decorators, the onError hook continues to intercept errors and can recover or re-throw as before. + +--- + +## Acceptance Criteria + +### Functional Requirements + +- [X] **Wrap decorator accepts a wrapper function**: Users can create decorators by passing a function of the form `(method, context: WrapContext) => (...args) => result` to Wrap. + - Given: A wrapper function that logs and delegates to the original method + - When: The Wrap decorator is applied to a class method and that method is called + - Then: The wrapper function receives the original method and a WrapContext, and the returned inner function is called with the method arguments, producing the expected result + +- [X] **WrapContext contains expected fields without args**: WrapContext includes target, propertyKey, parameterNames, className, and descriptor, but does not include args or argsObject. + - Given: A Wrap decorator applied to a method + - When: The wrapped method is called + - Then: The WrapContext passed to the wrapper function contains target (the class instance), propertyKey (the method name), parameterNames (extracted parameter names), className (from constructor.name), and descriptor (the property descriptor), and does not contain args or argsObject + +- [X] **HookContext extends WrapContext with args**: HookContext includes all WrapContext fields plus args (raw arguments array) and argsObject (parameter names mapped to values). + - Given: An Effect decorator applied to a method with an onInvoke hook + - When: The wrapped method is called with arguments + - Then: The HookContext passed to onInvoke contains all WrapContext fields plus args and argsObject with correct values + +- [X] **Wrap works as a method decorator**: Wrap can be applied to individual class methods. + - Given: A class with a single method decorated with Wrap + - When: The method is called + - Then: The wrapper function intercepts the call and can observe, modify, or replace the method behavior + +- [X] **Wrap works as a class decorator**: Wrap can be applied to a class, wrapping all eligible prototype methods (skipping constructor, getters/setters, and excluded methods). + - Given: A class decorated with Wrap containing 3 regular methods and 1 getter + - When: Each regular method is called + - Then: The wrapper function intercepts each regular method call, and the getter is not wrapped + +- [X] **Wrap handles synchronous methods**: Wrap correctly wraps methods that return values synchronously. + - Given: A synchronous method `add(a, b)` returning `a + b`, decorated with a Wrap that modifies the result + - When: The method is called with `(2, 3)` + - Then: The wrapper can call the original method, observe the synchronous return value, and return a modified result + +- [X] **Wrap handles asynchronous methods**: Wrap correctly wraps methods that return Promises. + - Given: An async method `fetchData()` returning a Promise, decorated with Wrap + - When: The method is called and the returned Promise is awaited + - Then: The wrapper can call the original method, await the Promise, and return a modified result + +- [X] **Effect maintains identical public interface**: Effect accepts the same parameters (HooksOrFactory and optional exclusionKey) as before the refactoring. + - Given: Existing code using `Effect({ onInvoke, onReturn, onError, finally }, exclusionKey)` + - When: The code is run against the refactored library + - Then: The Effect decorator works identically without any code changes required + +- [X] **Effect lifecycle hooks fire identically**: All four lifecycle hooks (onInvoke, onReturn, onError, finally) fire at the same points with the same context and produce the same results as the pre-refactor implementation. + - Given: An Effect decorator with all four hooks defined, applied to a method + - When: The method is called successfully, and separately when the method throws an error + - Then: onInvoke fires before execution, onReturn fires after success with the result, onError fires after failure with the error, and finally fires after either outcome -- all with complete HookContext including args and argsObject + +- [X] **Hook convenience decorators maintain identical behavior**: OnInvokeHook, OnReturnHook, OnErrorHook, and FinallyHook produce identical behavior to their pre-refactor versions. + - Given: Existing code using each hook convenience decorator + - When: The code is run against the refactored library + - Then: Each hook decorator works identically without any code changes required + +- [X] **Exclusion key mechanism works for Wrap**: Class-level Wrap skips methods marked with the exclusion key, and method-level Wrap marks methods to prevent double-wrapping. + - Given: A class with class-level Wrap and one method also having method-level Wrap with the same exclusion key + - When: The method-level wrapped method is called + - Then: Only the method-level wrapper executes (no double-wrapping from the class-level decorator) + +- [X] **Metadata preserved across Wrap wrapping**: SetMeta metadata set on original methods survives Wrap wrapping. + - Given: A method with `@SetMeta(SOME_KEY, someValue)` applied, which is also wrapped by Wrap + - When: `getMeta(SOME_KEY, descriptor)` is called on the wrapped method's descriptor + - Then: The original metadata value is returned + +- [X] **EffectOnMethod and EffectOnClass removed from public exports**: These identifiers are no longer exported from the library's public entry point. + - Given: The refactored library's public exports + - When: Inspecting the available exports + - Then: Neither `EffectOnMethod` nor `EffectOnClass` is available as a public export + +- [X] **README updated with Wrap documentation**: README includes Wrap decorator description, updated Quick Start section, updated How It Works section, and an async Wrap example in the usage section. + - Given: The updated README file + - When: A developer reads the documentation + - Then: They find a Wrap decorator section with usage examples, the Quick Start demonstrates Wrap, How It Works explains Wrap as the foundational primitive, and an async Wrap example is included + +- [X] **Build, lint, and tests pass**: `npm run lint` and `npm run test` complete successfully after all changes. + - Given: The fully refactored codebase with all changes applied + - When: `npm run lint` and `npm run test` are executed + - Then: Both commands exit with success (exit code 0) with no failures or errors + +### Non-Functional Requirements + +- [X] **Type safety**: The refactoring does not increase the number of `as` type assertions compared to the original codebase (13 <= 15 baseline) +- [X] **Zero dependencies**: No new external packages are added to the library +- [X] **Compatibility**: The library continues to work with the existing TypeScript and Node.js configuration + +### Definition of Done + +- [X] All acceptance criteria pass +- [X] Tests written and passing for Wrap decorator (method-level, class-level, sync, async) +- [X] Existing Effect and hook tests updated and passing +- [X] README documentation updated with Wrap examples +- [X] `npm run lint` passes +- [X] `npm run test` passes +- [ ] Code reviewed + +--- + +## Architecture + +### References + +- **Skill**: `.claude/skills/wrap-decorator/SKILL.md` +- **Codebase Analysis**: `.specs/analysis/analysis-add-wrap-decorator.md` +- **Scratchpad**: `.specs/scratchpad/5ac65572.md` + +### Solution Strategy + +**Architecture Pattern**: Layered -- `Wrap` (raw method wrapping) -> `Effect` (hook lifecycle orchestration built on Wrap) -> convenience hooks (thin API wrappers around Effect). This preserves the existing codebase's progressive abstraction pattern while introducing `Wrap` as a lower foundation layer. + +**Approach**: Split the current `effect-on-method.ts` into two concerns: (1) a generic method-wrapping primitive (`WrapOnMethod` in `wrap-on-method.ts`) that accepts a `WrapFn` factory and handles descriptor mutation, metadata copying, and exclusion keys; and (2) hook lifecycle orchestration (`buildArgsObject`, `attachHooks`, `resolveHooks`, `chainAsyncHooks`) moved into `effect.decorator.ts`. `Effect` constructs an internal `effectWrapFn` and returns `Wrap(effectWrapFn, exclusionKey)`, delegating ALL class/method dispatch logic to `Wrap`. The `Wrap` dispatcher follows the identical `propertyKey === undefined` pattern from the current `effect.decorator.ts`. + +**Key Decisions:** +1. **Effect delegates entirely to Wrap**: `Effect` returns `Wrap(effectWrapFn, exclusionKey)` rather than having its own dispatcher. This eliminates duplicated dispatch logic and 3 `as` type assertions, keeping the total assertion count at 15 (equal to baseline per `preserve-type-safety-during-refactoring.md`). +2. **Preserve attachHooks signature unchanged**: Keep `(originalMethod, thisArg, args, context, hooks)` -- because the task says "simply move existing logic" and the analysis confirms the signature remains unchanged. The pre-bound method + `.apply(thisArg, args)` is functionally equivalent (bind takes precedence over apply for `this`). +3. **WrapFn called per invocation**: The factory `wrapFn(boundMethod, context)` is called inside the runtime wrapper function, giving access to runtime `this` and `className`. This matches Pattern 2 from the skill file. +4. **WRAP_APPLIED_KEY as new default sentinel**: Replaces `EFFECT_APPLIED_KEY`. Internal to `wrap-on-method.ts`, not exported from `index.ts`. +5. **HookContext extends WrapContext via interface extension**: Avoids any new `as` casts. Clean TypeScript interface hierarchy. + +**Trade-offs Accepted:** +- Breaking change: Removing `EffectOnMethod`, `EffectOnClass`, `EFFECT_APPLIED_KEY` from public exports -- acceptable per task scope +- Per-invocation factory overhead in WrapOnMethod: Necessary for runtime context (`this`, `className`) +- `attachHooks` receives both pre-bound method and `thisArg`: Minor redundancy, but preserves exact existing behavior with zero risk + +### Architecture Decomposition + +**Components:** + +| Component | Responsibility | Dependencies | +|-----------|---------------|--------------| +| `WrapContext` (type in hook.types.ts) | Base context type without args/argsObject | None | +| `WrapFn` (type in hook.types.ts) | Factory signature `(method, context) => (...args) => R` | WrapContext | +| `HookContext` (type in hook.types.ts) | Extends WrapContext with args and argsObject | WrapContext | +| `WrapOnMethod` (src/wrap-on-method.ts) | Core method wrapping: extract param names at decoration time, call WrapFn per invocation, copy sym meta, set exclusion key | getParameterNames, setMeta, SYM_META_PROP, WrapFn, WrapContext | +| `WrapOnClass` (src/wrap-on-class.ts) | Iterate prototype methods, apply WrapOnMethod to eligible ones, skip constructor/getters/excluded | getMeta, WrapFn, WrapOnMethod, WRAP_APPLIED_KEY | +| `Wrap` (src/wrap.decorator.ts) | Public dispatcher: class vs method based on argument count | WrapOnClass, WrapOnMethod, WrapFn | +| `Effect` (src/effect.decorator.ts) | Hook lifecycle layer: constructs effectWrapFn, returns `Wrap(effectWrapFn, exclusionKey)` | Wrap, WrapContext, HookContext, HooksOrFactory | + +**Interactions:** + +``` +User Code + | + v +[Wrap] ──dispatch──> [WrapOnClass] ──iterate──> [WrapOnMethod] + | | + +──dispatch─────────────────────────────> [WrapOnMethod] + | + v + wrapFn(boundMethod, WrapContext) + | + v + (...args) => result + +[Effect] ──constructs effectWrapFn──> [Wrap] + | + v + (same dispatch as above) +``` + +### Runtime Scenarios + +**Scenario: Wrap applied to a method** + +``` +User calls wrappedMethod(arg1, arg2) + | + v +WrapOnMethod's inner function(this, ...args) + | + +-- boundMethod = originalMethod.bind(this) + +-- wrapContext = { target: this, propertyKey, parameterNames, className, descriptor } + +-- innerFn = wrapFn(boundMethod, wrapContext) + +-- return innerFn(arg1, arg2) +``` + +**Scenario: Effect applied to a method (delegates to Wrap)** + +``` +User calls effectDecoratedMethod(arg1, arg2) + | + v +WrapOnMethod's inner function(this, ...args) + | + +-- boundMethod = originalMethod.bind(this) + +-- wrapContext = { target: this, propertyKey, parameterNames, className, descriptor } + +-- innerFn = effectWrapFn(boundMethod, wrapContext) + | | + | v + | returns (...args) => { + | argsObject = buildArgsObject(parameterNames, args) + | hookContext = { ...wrapContext, args, argsObject } + | hooks = resolveHooks(hooksOrFactory, hookContext) + | executeMethod = attachHooks(boundMethod, this, args, hookContext, hooks) + | if hooks.onInvoke: + | result = hooks.onInvoke(hookContext) + | if result is Promise: return result.then(executeMethod) + | return executeMethod() + | } + +-- return innerFn(arg1, arg2) +``` + +### Architecture Decisions + +#### Effect delegates to Wrap instead of having its own dispatcher + +**Status**: Accepted + +**Context**: The current `Effect` in `effect.decorator.ts` contains its own class/method dispatch logic. With `Wrap` now providing the same dispatch, `Effect` can delegate. + +**Options:** +1. Effect has its own dispatcher (duplicates Wrap's logic, adds 3 `as` casts) +2. Effect returns `Wrap(effectWrapFn, exclusionKey)` (reuses Wrap's dispatch, zero extra casts) + +**Decision**: Option 2 -- Effect returns `Wrap(effectWrapFn, exclusionKey)`. This eliminates code duplication and keeps the `as` assertion count at 15 (equal to baseline). + +**Consequences:** +- Single source of truth for class/method dispatch (wrap.decorator.ts) +- Effect becomes a pure hook orchestration layer with no dispatch logic +- Total `as` assertion count: wrap-on-method.ts (8) + wrap-on-class.ts (2) + wrap.decorator.ts (3) + effect.decorator.ts (2) = 15 + +#### Preserve attachHooks signature unchanged + +**Status**: Accepted + +**Context**: The skill file suggests dropping `thisArg` from `attachHooks` since `Wrap` pre-binds the method. The analysis file says to preserve the signature. + +**Options:** +1. Drop `thisArg` -- cleaner API but changes existing logic +2. Keep `thisArg` -- safer migration, move logic as-is + +**Decision**: Option 2 -- Keep `thisArg`. The task says "simply move existing logic." Calling `.apply(thisArg, args)` on a pre-bound function is a no-op for `this` binding (bind takes precedence), so behavior is identical. + +**Consequences:** +- Zero risk of subtle `this`-binding regressions +- attachHooks tests can be preserved with minimal changes +- Minor redundancy (method is both bound and applied with thisArg) + +### Expected Changes + +``` +src/ ++-- wrap-on-method.ts # NEW: WRAP_APPLIED_KEY, WrapOnMethod, copySymMeta (from effect-on-method.ts) ++-- wrap-on-class.ts # NEW: WrapOnClass, isPlainMethod, shouldSkipMethod (from effect-on-class.ts) ++-- wrap.decorator.ts # NEW: Wrap public dispatcher +--- effect-on-method.ts # DELETE: Split to wrap-on-method.ts + effect.decorator.ts +--- effect-on-class.ts # DELETE: Moved to wrap-on-class.ts +~~~ effect.decorator.ts # UPDATE: Gains buildArgsObject, attachHooks, resolveHooks, chainAsyncHooks; + # Effect returns Wrap(effectWrapFn, exclusionKey) +~~~ hook.types.ts # UPDATE: Add WrapContext, WrapFn; HookContext extends WrapContext +~~~ index.ts # UPDATE: Remove effect-on-method/effect-on-class; add wrap.decorator + +tests/ ++-- WrapOnMethod.spec.ts # NEW ++-- WrapOnClass.spec.ts # NEW ++-- Wrap.spec.ts # NEW +--- EffectOnMethod.spec.ts # DELETE +--- EffectOnClass.spec.ts # DELETE +--- effect-on-method-base.spec.ts # DELETE/REWRITE (wrapFunction eliminated; attachHooks moved) + Effect.spec.ts # VERIFY (should pass unchanged) + OnInvokeHook.spec.ts # VERIFY + OnReturnHook.spec.ts # VERIFY + OnErrorHook.spec.ts # VERIFY + FinallyHook.spec.ts # VERIFY + SetMeta.spec.ts # VERIFY + getParameterNames.spec.ts # VERIFY + +~~~ README.md # UPDATE: Wrap docs, Quick Start, How It Works, async example +``` + +### Workflow Steps + +``` +Phase 1: Types ++-- 1.1 Update hook.types.ts: Add WrapContext, WrapFn; HookContext extends WrapContext +| +Phase 2: Core Wrap Primitive (depends on Phase 1) ++-- 2.1 Create src/wrap-on-method.ts: WRAP_APPLIED_KEY, WrapOnMethod, copySymMeta ++-- 2.2 Create src/wrap-on-class.ts: WrapOnClass, isPlainMethod, shouldSkipMethod ++-- 2.3 Create src/wrap.decorator.ts: Wrap dispatcher +| +Phase 3: Refactor Effect (depends on Phase 2) ++-- 3.1 Rewrite effect.decorator.ts: Move hook functions from effect-on-method.ts; +| Effect returns Wrap(effectWrapFn, exclusionKey) ++-- 3.2 Delete src/effect-on-method.ts and src/effect-on-class.ts +| +Phase 4: Update Exports (depends on Phases 2+3) ++-- 4.1 Update src/index.ts ++-- 4.2 Verify: npm run typecheck +| +Phase 5: Tests (depends on Phase 4) ++-- 5.1 Create tests/WrapOnMethod.spec.ts ++-- 5.2 Create tests/WrapOnClass.spec.ts ++-- 5.3 Create tests/Wrap.spec.ts ++-- 5.4 Delete old test files (EffectOnMethod, EffectOnClass, effect-on-method-base) ++-- 5.5 Verify: npm run test && npm run lint +| +Phase 6: Documentation (depends on Phase 5) ++-- 6.1 Update README.md ++-- 6.2 Final: npm run lint && npm run test +``` + +### Contracts + +**WrapContext Interface** (new, in hook.types.ts): +```typescript +interface WrapContext { + target: object; + propertyKey: string | symbol; + parameterNames: string[]; + className: string; + descriptor: PropertyDescriptor; +} +``` + +**WrapFn Type** (new, in hook.types.ts): +```typescript +type WrapFn = ( + method: (...args: unknown[]) => unknown, + context: WrapContext, +) => (...args: unknown[]) => R; +``` + +**HookContext Interface** (refactored, in hook.types.ts): +```typescript +interface HookContext extends WrapContext { + args: unknown[]; + argsObject: HookArgs; +} +``` + +**Wrap Public API** (new, in wrap.decorator.ts): +```typescript +const Wrap: ( + wrapFn: WrapFn, + exclusionKey?: symbol, +) => ClassDecorator & MethodDecorator; +``` + +**Effect Public API** (unchanged signature, in effect.decorator.ts): +```typescript +const Effect: ( + hooks: HooksOrFactory, + exclusionKey?: symbol, +) => ClassDecorator & MethodDecorator; +``` + +**index.ts Exports** (updated): +```typescript +export * from './wrap.decorator'; // Wrap (public) +export * from './effect.decorator'; // Effect, buildArgsObject (if kept public) +export type * from './hook.types'; // WrapContext, WrapFn, HookContext, etc. +export * from './set-meta.decorator'; +export * from './on-invoke.hook'; +export * from './on-return.hook'; +export * from './on-error.hook'; +export * from './finally.hook'; +// NOT exported: wrap-on-method.ts, wrap-on-class.ts (internal) +``` + +--- + +## Implementation Process + +You MUST launch for each step a separate agent, instead of performing all steps yourself. And for each step marked as parallel, you MUST launch separate agents in parallel. + +**CRITICAL:** For each agent you MUST: +1. Use the **Agent** type specified in the step (e.g., `haiku`, `sonnet`, `sdd:developer`, `sdd:tech-writer`) +2. Provide path to task file and prompt which step to implement +3. Require agent to implement exactly that step, not more, not less, not other steps + +### Implementation Strategy + +**Approach**: Bottom-Up (Building-Blocks-First) +**Rationale**: The task extracts and reorganizes existing logic into a new layered architecture: `Wrap` (raw wrapping) -> `Effect` (hook orchestration) -> convenience hooks. The lowest-level building blocks (types, then WrapOnMethod, then WrapOnClass) must exist before higher-level components (Wrap, Effect) can reference them. Bottom-up ensures each foundation layer is solid and type-checked before the next layer builds on it. The core algorithms are already well-defined in the existing codebase, so the primary challenge is correct extraction and wiring -- not algorithmic design. + +### Parallelization Overview + +``` +Step 1 (Update Types) [sdd:developer] + | + v +Step 2 (WrapOnMethod) [sdd:developer] + | + v +Step 3 (WrapOnClass) [sdd:developer] + | + v +Step 4 (Wrap Dispatcher) [sdd:developer] + | + v +Step 5 (Refactor Effect + Delete Old Source & Test Files) [sdd:developer] + | + |------------------------|----------------------|---------------------| + v v v v +Step 6 Step 7 Step 8 Step 9 +(Update Exports) (WrapOnMethod Tests) (WrapOnClass Tests) (Wrap Tests) +[sdd:developer] [sdd:developer] [sdd:developer] [sdd:developer] +(MUST parallel) (MUST parallel) (MUST parallel) (MUST parallel) + | | | | + |------------------------|----------------------|---------------------| + v +Step 10 (Full Test + Lint Suite Verification) [haiku] + | + v +Step 11 (README Documentation) [sdd:tech-writer] +``` + +**Phase Overview:** +- **Phase 1 - Foundation (Steps 1-4):** Sequential chain. Types -> WrapOnMethod -> WrapOnClass -> Wrap Dispatcher. Each step produces artifacts consumed by the next. +- **Phase 2 - Migration (Step 5):** Refactor Effect to use Wrap, delete old source files AND old test files. This eliminates a broken intermediate state. +- **Phase 3 - Exports + Tests (Steps 6-9):** All four steps MUST run in parallel. They touch different files and have no cross-dependencies. +- **Phase 4 - Verification (Step 10):** Synchronization barrier. Run full test + lint suite after all parallel work completes. +- **Phase 5 - Documentation (Step 11):** Update README after all code is verified working. + +--- + +### Step 1: Update Type Definitions in hook.types.ts + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** None +**Parallel with:** None + +**Goal**: Add `WrapContext` and `WrapFn` types; refactor `HookContext` to extend `WrapContext` so downstream code gets the correct type hierarchy with zero new `as` assertions. + +#### Expected Output + +- `src/hook.types.ts`: Updated with `WrapContext` interface, `WrapFn` type, and `HookContext extends WrapContext` + +#### Success Criteria + +- [X] `WrapContext` interface exists in `src/hook.types.ts` with fields: `target`, `propertyKey`, `parameterNames`, `className`, `descriptor` +- [X] `WrapContext` does NOT contain `args` or `argsObject` +- [X] `WrapFn` type exists: `(method: (...args: unknown[]) => unknown, context: WrapContext) => (...args: unknown[]) => R` +- [X] `HookContext` extends `WrapContext` and only adds `args` and `argsObject` +- [X] `HookContext` shape is identical to the pre-refactor version (all 7 fields present) +- [X] All existing type exports (`HookArgs`, `OnReturnContext`, `OnErrorContext`, hook types, `EffectHooks`, `HooksOrFactory`) remain unchanged +- [X] `npm run typecheck` passes (existing code still compiles against updated types) + +#### Subtasks + +- [X] Add `WrapContext` interface to `src/hook.types.ts` with JSDoc +- [X] Add `WrapFn` type alias to `src/hook.types.ts` with JSDoc +- [X] Refactor `HookContext` to `interface HookContext extends WrapContext` keeping only `args` and `argsObject` +- [X] Verify exported type list is correct (WrapContext and WrapFn added to exports) +- [X] Run `npm run typecheck` to verify no downstream breakage + +#### Verification + +**Level:** βœ… CRITICAL - Panel of 2 Judges with Aggregated Voting +**Artifact:** `src/hook.types.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Type Correctness | 0.30 | WrapContext has exactly the 5 required fields (target, propertyKey, parameterNames, className, descriptor); WrapFn signature matches spec | +| Interface Hierarchy | 0.25 | HookContext extends WrapContext and only adds args and argsObject; no field duplication | +| Backward Compatibility | 0.25 | All existing type exports (HookArgs, OnReturnContext, OnErrorContext, hook types, EffectHooks, HooksOrFactory) remain unchanged | +| Completeness | 0.10 | Both WrapContext and WrapFn are exported from the types file | +| Code Quality | 0.10 | JSDoc present, follows existing file conventions, no unnecessary `as` assertions | + +**Reference Pattern:** `src/hook.types.ts` (pre-refactoring state for existing exports verification) + +**Complexity**: Small +**Uncertainty**: Low +**Blockers**: None +**Risks**: None -- additive type change with identical runtime shape + +--- + +### Step 2: Create WrapOnMethod (Core Method Wrapping Primitive) + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 1 +**Parallel with:** None + +**Goal**: Create `src/wrap-on-method.ts` containing the low-level method decorator that accepts a `WrapFn`, wraps `descriptor.value`, copies symbol metadata, and sets exclusion key. This replaces `EffectOnMethod` as the core wrapping mechanism. + +#### Expected Output + +- `src/wrap-on-method.ts` (NEW): Contains `WRAP_APPLIED_KEY`, `WrapOnMethod`, `copySymMeta` (private) + +#### Success Criteria + +- [X] `WRAP_APPLIED_KEY` is a `unique symbol` declared in `src/wrap-on-method.ts` +- [X] `WrapOnMethod(wrapFn: WrapFn, exclusionKey?: symbol): MethodDecorator` function exists +- [X] `WrapOnMethod` extracts parameter names at decoration time via `getParameterNames` +- [X] `WrapOnMethod` creates a wrapped function that: binds original method to `this`, builds `WrapContext` (target, propertyKey, parameterNames, className, descriptor), calls `wrapFn(boundMethod, context)`, returns `innerFn(...args)` +- [X] `copySymMeta` (private) copies `_symMeta` Map from original to wrapped function (moved verbatim from `src/effect-on-method.ts` lines 208-229) +- [X] `WrapOnMethod` calls `copySymMeta` after wrapping +- [X] `WrapOnMethod` calls `setMeta(exclusionKey, true, descriptor)` after wrapping +- [X] `exclusionKey` defaults to `WRAP_APPLIED_KEY` when not provided +- [X] `as` type assertions in this file total 8 or fewer (matching effect-on-method.ts WrapOnMethod-relevant assertions) +- [X] `npm run typecheck` passes + +#### Subtasks + +- [X] Create `src/wrap-on-method.ts` with imports: `{ setMeta, SYM_META_PROP }` from `./set-meta.decorator`, `{ getParameterNames }` from `./getParameterNames`, `type { WrapFn, WrapContext }` from `./hook.types` +- [X] Declare `WRAP_APPLIED_KEY: unique symbol = Symbol('wrapApplied')` (exported) +- [X] Copy `copySymMeta` function verbatim from `src/effect-on-method.ts` lines 208-229 (private) +- [X] Implement `WrapOnMethod` following the decoration pattern from `src/effect-on-method.ts` lines 48-78 but replacing hook logic with generic `WrapFn` call per Pattern 2 from skill file +- [X] Verify `this` binding: `const boundMethod = originalMethod.bind(this)` inside the wrapped function closure +- [X] Verify `className` extraction: `(this.constructor as { name: string }).name ?? ''` +- [X] Run `npm run typecheck` + +#### Verification + +**Level:** βœ… CRITICAL - Panel of 2 Judges with Aggregated Voting +**Artifact:** `src/wrap-on-method.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Correctness | 0.25 | WrapOnMethod extracts param names at decoration time, calls wrapFn per invocation with bound method and WrapContext, returns innerFn result | +| This Binding | 0.20 | `originalMethod.bind(this)` inside wrapped function closure; className extracted via `this.constructor.name` | +| Metadata Handling | 0.20 | copySymMeta copies _symMeta Map from original to wrapped function; exclusionKey set on descriptor via setMeta | +| Per-Invocation Pattern | 0.15 | WrapFn factory called inside runtime wrapper (not at decoration time); boundMethod created per call | +| Type Safety | 0.10 | `as` type assertions <= 8; proper TypeScript types used | +| Code Quality | 0.10 | Follows existing codebase conventions; proper imports | + +**Reference Pattern:** `src/effect-on-method.ts` (pre-refactoring state, lines 48-78 for decoration pattern, lines 208-229 for copySymMeta) + +**Complexity**: Medium +**Uncertainty**: Medium -- `this` binding and per-invocation factory pattern require careful implementation +**Blockers**: None +**Risks**: +- `this` binding lost if `originalMethod.bind(this)` omitted -> Test with class instances +- WrapFn called at decoration time instead of per invocation -> Factory must be inside the `wrapped = function(this)` closure +**Integration Points**: Used by WrapOnClass (Step 3) and Wrap dispatcher (Step 4) + +--- + +### Step 3: Create WrapOnClass (Class Iteration Decorator) + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Steps 1, 2 +**Parallel with:** None + +**Goal**: Create `src/wrap-on-class.ts` containing the class decorator that iterates prototype methods and applies `WrapOnMethod` to eligible ones. This replaces `EffectOnClass`. + +#### Expected Output + +- `src/wrap-on-class.ts` (NEW): Contains `WrapOnClass`, `isPlainMethod` (private), `shouldSkipMethod` (private) + +#### Success Criteria + +- [X] `WrapOnClass(wrapFn: WrapFn, exclusionKey?: symbol): ClassDecorator` function exists +- [X] `WrapOnClass` creates a `WrapOnMethod` instance internally with same `wrapFn` and `exclusionKey` +- [X] `WrapOnClass` iterates `Object.getOwnPropertyNames(prototype)`, skipping: `constructor`, non-functions, getters/setters, methods excluded by `exclusionKey` +- [X] `isPlainMethod` (private) moved verbatim from `src/effect-on-class.ts` lines 87-90 +- [X] `shouldSkipMethod` (private) moved verbatim from `src/effect-on-class.ts` lines 100-105 +- [X] `exclusionKey` defaults to `WRAP_APPLIED_KEY` when not provided +- [X] `as` type assertions in this file total 2 or fewer (matching effect-on-class.ts) +- [X] `npm run typecheck` passes + +#### Subtasks + +- [X] Create `src/wrap-on-class.ts` with imports: `{ getMeta }` from `./set-meta.decorator`, `type { WrapFn }` from `./hook.types`, `{ WrapOnMethod, WRAP_APPLIED_KEY }` from `./wrap-on-method` +- [X] Copy `isPlainMethod` function verbatim from `src/effect-on-class.ts` lines 87-90 (private) +- [X] Copy `shouldSkipMethod` function verbatim from `src/effect-on-class.ts` lines 100-105 (private) +- [X] Implement `WrapOnClass` following the pattern from `src/effect-on-class.ts` lines 50-78 but using `WrapOnMethod` and `WRAP_APPLIED_KEY` instead of `EffectOnMethod` and `EFFECT_APPLIED_KEY` +- [X] Run `npm run typecheck` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `src/wrap-on-class.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Correctness | 0.30 | Iterates Object.getOwnPropertyNames(prototype), creates WrapOnMethod internally, applies to eligible methods | +| Skip Logic | 0.25 | Correctly skips constructor, non-functions, getters/setters (isPlainMethod), excluded methods (shouldSkipMethod) | +| Code Fidelity | 0.20 | isPlainMethod and shouldSkipMethod moved verbatim from effect-on-class.ts | +| Type Safety | 0.15 | `as` type assertions <= 2; proper TypeScript types | +| Code Quality | 0.10 | Follows existing conventions; proper imports from wrap-on-method | + +**Reference Pattern:** `src/effect-on-class.ts` (pre-refactoring state, lines 50-78 for WrapOnClass pattern, lines 87-105 for helper functions) + +**Complexity**: Small +**Uncertainty**: Low -- direct adaptation of existing effect-on-class.ts +**Blockers**: None +**Risks**: None -- straightforward code move with import path changes +**Integration Points**: Used by Wrap dispatcher (Step 4) + +--- + +### Step 4: Create Wrap Public Dispatcher + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Steps 2, 3 +**Parallel with:** None + +**Goal**: Create `src/wrap.decorator.ts` containing the public `Wrap` decorator that dispatches to `WrapOnClass` or `WrapOnMethod` based on argument count, exactly like the current `Effect` dispatcher. + +#### Expected Output + +- `src/wrap.decorator.ts` (NEW): Contains `Wrap` function + +#### Success Criteria + +- [X] `Wrap(wrapFn: WrapFn, exclusionKey?: symbol): ClassDecorator & MethodDecorator` function exists and is exported +- [X] When applied to a class (1 argument, `propertyKey === undefined`), delegates to `WrapOnClass` +- [X] When applied to a method (3 arguments, `descriptor !== undefined`), delegates to `WrapOnMethod` +- [X] Throws `Error` with descriptive message for invalid context +- [X] `as` type assertions in this file total 3 or fewer (matching effect.decorator.ts dispatcher) +- [X] `npm run typecheck` passes + +#### Subtasks + +- [X] Create `src/wrap.decorator.ts` with imports: `{ WrapOnClass }` from `./wrap-on-class`, `{ WrapOnMethod }` from `./wrap-on-method`, `type { WrapFn }` from `./hook.types` +- [X] Implement `Wrap` following the dispatcher pattern from `src/effect.decorator.ts` lines 48-73, replacing `EffectOnClass`/`EffectOnMethod` with `WrapOnClass`/`WrapOnMethod` and replacing `HooksOrFactory` param with `WrapFn` +- [X] Verify error message for invalid context: `'Wrap decorator can only be applied to classes or methods'` +- [X] Run `npm run typecheck` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `src/wrap.decorator.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Dispatch Correctness | 0.35 | Class decoration (1 arg, propertyKey undefined) delegates to WrapOnClass; method decoration (3 args) delegates to WrapOnMethod | +| API Shape | 0.25 | Exported function signature matches: `Wrap(wrapFn: WrapFn, exclusionKey?: symbol): ClassDecorator & MethodDecorator` | +| Error Handling | 0.20 | Throws descriptive Error for invalid decorator context | +| Type Safety | 0.10 | `as` type assertions <= 3 | +| Code Quality | 0.10 | Follows pattern from existing effect.decorator.ts dispatcher | + +**Reference Pattern:** `src/effect.decorator.ts` (pre-refactoring state, lines 48-73 for dispatcher pattern) + +**Complexity**: Small +**Uncertainty**: Low -- direct adaptation of existing effect.decorator.ts dispatcher +**Blockers**: None +**Risks**: None +**Integration Points**: Used by Effect (Step 5), exported publicly via index.ts (Step 6) + +--- + +### Step 5: Refactor Effect to Use Wrap, Delete Old Source and Test Files + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 4 +**Parallel with:** None + +**Goal**: Rewrite `effect.decorator.ts` so that `Effect` constructs an internal `effectWrapFn` and returns `Wrap(effectWrapFn, exclusionKey)`, delegating all class/method dispatch logic to Wrap. Move `buildArgsObject`, `attachHooks`, `resolveHooks`, and `chainAsyncHooks` from `effect-on-method.ts` into `effect.decorator.ts`. Delete the now-unused `src/effect-on-method.ts`, `src/effect-on-class.ts`, AND obsolete test files (`tests/EffectOnMethod.spec.ts`, `tests/EffectOnClass.spec.ts`, `tests/effect-on-method-base.spec.ts`). + +**Note**: Old test files MUST be deleted in this step (not later) because they import from deleted source files and would cause typecheck/test failures in subsequent parallel steps. + +#### Expected Output + +- `src/effect.decorator.ts` (UPDATED): Contains `Effect`, `buildArgsObject`, `attachHooks` (private), `resolveHooks` (private), `chainAsyncHooks` (private), `effectWrapFn` construction +- `src/effect-on-method.ts` (DELETED) +- `src/effect-on-class.ts` (DELETED) +- `tests/EffectOnMethod.spec.ts` (DELETED) +- `tests/EffectOnClass.spec.ts` (DELETED) +- `tests/effect-on-method-base.spec.ts` (DELETED) + +#### Success Criteria + +- [X] `Effect` function signature unchanged: `(hooks: HooksOrFactory, exclusionKey?: symbol): ClassDecorator & MethodDecorator` +- [X] `Effect` internally constructs an `effectWrapFn` of type `WrapFn` and returns `Wrap(effectWrapFn, exclusionKey)` +- [X] `effectWrapFn` receives `(boundMethod, wrapContext)` and returns `(...args) => unknown` that: calls `buildArgsObject`, builds `HookContext` from `WrapContext + args + argsObject`, calls `resolveHooks`, calls `attachHooks`, checks `onInvoke` (sync/async), returns result +- [X] `buildArgsObject` moved verbatim from `src/effect-on-method.ts` lines 98-115 +- [X] `attachHooks` moved verbatim from `src/effect-on-method.ts` lines 164-196 with unchanged signature `(originalMethod, thisArg, args, context, hooks)` +- [X] `resolveHooks` moved verbatim from `src/effect-on-method.ts` lines 237-245 (private) +- [X] `chainAsyncHooks` moved verbatim from `src/effect-on-method.ts` lines 253-275 (private) +- [X] `Effect` no longer imports from `./effect-on-method` or `./effect-on-class` +- [X] `Effect` imports `{ Wrap }` from `./wrap.decorator` +- [X] `src/effect-on-method.ts` deleted +- [X] `src/effect-on-class.ts` deleted +- [X] `tests/EffectOnMethod.spec.ts` deleted +- [X] `tests/EffectOnClass.spec.ts` deleted +- [X] `tests/effect-on-method-base.spec.ts` deleted +- [X] Total `as` type assertions across `wrap-on-method.ts`, `wrap-on-class.ts`, `wrap.decorator.ts`, `effect.decorator.ts` is <= 15 (baseline count) +- [X] `npm run typecheck` passes + +#### Subtasks + +- [X] Move `buildArgsObject` function from `src/effect-on-method.ts` to `src/effect.decorator.ts` +- [X] Move `attachHooks` function from `src/effect-on-method.ts` to `src/effect.decorator.ts` (keep same signature with `thisArg`) +- [X] Move `resolveHooks` function from `src/effect-on-method.ts` to `src/effect.decorator.ts` (private) +- [X] Move `chainAsyncHooks` function from `src/effect-on-method.ts` to `src/effect.decorator.ts` (private) +- [X] Construct `effectWrapFn` inside `Effect` following Pattern 3 from skill file (`.claude/skills/wrap-decorator/SKILL.md` lines 99-112) +- [X] Replace current `Effect` body with: `return Wrap(effectWrapFn, exclusionKey)` -- removing its own class/method dispatcher logic +- [X] Update imports in `src/effect.decorator.ts`: add `{ Wrap }` from `./wrap.decorator`; add `type { WrapContext }` from `./hook.types`; remove `{ EffectOnMethod }` and `{ EffectOnClass }` +- [X] Delete `src/effect-on-method.ts` +- [X] Delete `src/effect-on-class.ts` +- [X] Delete `tests/EffectOnMethod.spec.ts` +- [X] Delete `tests/EffectOnClass.spec.ts` +- [X] Delete `tests/effect-on-method-base.spec.ts` +- [X] Count `as` type assertions across all 4 refactored source files; verify total <= 15 +- [X] Run `npm run typecheck` + +#### Verification + +**Level:** βœ… CRITICAL - Panel of 2 Judges with Aggregated Voting +**Artifact:** `src/effect.decorator.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Behavioral Equivalence | 0.30 | Effect produces identical behavior for all hooks (onInvoke, onReturn, onError, finally) in both sync and async scenarios | +| Function Migration | 0.25 | buildArgsObject, attachHooks, resolveHooks, chainAsyncHooks moved verbatim from effect-on-method.ts with unchanged signatures | +| Delegation Pattern | 0.20 | Effect constructs effectWrapFn and returns Wrap(effectWrapFn, exclusionKey); no own dispatcher logic | +| File Cleanup | 0.10 | effect-on-method.ts, effect-on-class.ts deleted; 3 obsolete test files deleted | +| Type Safety | 0.15 | Total `as` assertions across wrap-on-method.ts + wrap-on-class.ts + wrap.decorator.ts + effect.decorator.ts <= 15 | + +**Reference Pattern:** `src/effect-on-method.ts` (pre-refactoring state, lines 98-115 for buildArgsObject, lines 164-196 for attachHooks, lines 237-275 for resolveHooks/chainAsyncHooks) + +**Complexity**: Large +**Uncertainty**: Medium -- must faithfully move 4 functions and construct effectWrapFn bridge while maintaining exact behavioral equivalence +**Blockers**: None +**Risks**: +- Hook behavior regression if functions not moved verbatim -> Copy exact function bodies, only change import paths +- `as` assertion count exceeds 15 -> Use `HookContext extends WrapContext` (no new casts needed) +- `attachHooks` `this` binding subtle issue -> Keep `thisArg` parameter unchanged; `.apply(thisArg, args)` on pre-bound method is a no-op for `this` (bind takes precedence) +**Integration Points**: Effect is consumed by all convenience hook decorators (on-invoke.hook.ts, on-return.hook.ts, on-error.hook.ts, finally.hook.ts) + +--- + +### Step 6: Update Exports in index.ts + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 5 +**Parallel with:** Steps 7, 8, 9 -- all four MUST be launched in parallel + +**Goal**: Update `src/index.ts` to remove old module exports and add the new `wrap.decorator` export, ensuring the public API reflects the refactored architecture. + +#### Expected Output + +- `src/index.ts` (UPDATED): Exports `wrap.decorator`; no longer exports `effect-on-method` or `effect-on-class` + +#### Success Criteria + +- [X] `export * from './effect-on-method'` line removed from `src/index.ts` +- [X] `export * from './effect-on-class'` line removed from `src/index.ts` +- [X] `export * from './wrap.decorator'` line added to `src/index.ts` (exports `Wrap`) +- [X] `export * from './effect.decorator'` unchanged (exports `Effect`, `buildArgsObject`) +- [X] `export type * from './hook.types'` unchanged (now also exports `WrapContext`, `WrapFn`) +- [X] All other export lines unchanged (set-meta.decorator, hook files) +- [X] Comment noting `wrap-on-method.ts` and `wrap-on-class.ts` are intentionally not exported +- [X] `npm run typecheck` passes +- [X] `npm run build` passes + +#### Subtasks + +- [X] Remove `export * from './effect-on-method'` from `src/index.ts` +- [X] Remove `export * from './effect-on-class'` from `src/index.ts` +- [X] Add `export * from './wrap.decorator'` to `src/index.ts` +- [X] Add comment: `// Internal (not exported): wrap-on-method.ts, wrap-on-class.ts` +- [X] Run `npm run typecheck` +- [X] Run `npm run build` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `src/index.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Export Correctness | 0.35 | wrap.decorator exported; effect-on-method and effect-on-class removed; effect.decorator and hook.types unchanged | +| Completeness | 0.25 | All required exports present (wrap.decorator, effect.decorator, hook.types, set-meta, all hooks) | +| Internal Modules | 0.20 | wrap-on-method.ts and wrap-on-class.ts NOT exported; comment noting intentional non-export | +| Build Verification | 0.20 | Both typecheck and build pass | + +**Complexity**: Small +**Uncertainty**: Low +**Blockers**: None +**Risks**: None +**Integration Points**: This is the public API surface; all consumers import via index.ts + +--- + +### Step 7: Write WrapOnMethod Tests + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 5 +**Parallel with:** Steps 6, 8, 9 -- all four MUST be launched in parallel + +**Goal**: Create comprehensive unit tests for `WrapOnMethod` covering sync/async wrapping, `this` binding, WrapContext fields, copySymMeta, exclusion key, and WRAP_APPLIED_KEY sentinel. + +#### Expected Output + +- `tests/WrapOnMethod.spec.ts` (NEW) + +#### Success Criteria + +- [X] Test file `tests/WrapOnMethod.spec.ts` exists +- [X] Tests cover: WrapFn called per invocation with bound method and WrapContext +- [X] Tests cover: WrapContext contains correct fields (target, propertyKey, parameterNames, className, descriptor) +- [X] Tests cover: WrapContext does NOT contain args or argsObject +- [X] Tests cover: sync method wrapping (return value passthrough and modification) +- [X] Tests cover: async method wrapping (Promise handling) +- [X] Tests cover: `this` binding preserved (bound method has correct `this`) +- [X] Tests cover: `WRAP_APPLIED_KEY` set on descriptor after wrapping +- [X] Tests cover: custom `exclusionKey` set on descriptor when provided +- [X] Tests cover: `copySymMeta` copies existing `_symMeta` from original to wrapped function +- [X] Tests cover: parameter names extracted correctly +- [X] All new tests pass: `npm run test` + +#### Subtasks + +- [X] Create `tests/WrapOnMethod.spec.ts` importing `WrapOnMethod` and `WRAP_APPLIED_KEY` from `../src/wrap-on-method` +- [X] Write test: WrapFn receives bound method and WrapContext on each invocation +- [X] Write test: WrapContext fields are correct (target is class instance, propertyKey is method name, etc.) +- [X] Write test: sync method wrapping works (wrapper can observe and modify result) +- [X] Write test: async method wrapping works (wrapper can await and modify result) +- [X] Write test: `this` binding is correct inside wrapper's bound method +- [X] Write test: WRAP_APPLIED_KEY metadata set on descriptor +- [X] Write test: custom exclusionKey metadata set on descriptor +- [X] Write test: symbol metadata copied from original to wrapped function +- [X] Write test: parameter names extracted from function signature +- [X] Run `npm run test` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `tests/WrapOnMethod.spec.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Coverage | 0.30 | Tests cover all 11 success criteria: WrapFn per invocation, WrapContext fields, no args/argsObject, sync, async, this binding, WRAP_APPLIED_KEY, custom exclusionKey, copySymMeta, param names | +| Edge Cases | 0.25 | Async promise handling, metadata preservation, exclusion key behavior | +| Correctness | 0.20 | Tests actually assert the right things (not just running without error) | +| Isolation | 0.15 | Tests independent; no shared mutable state between tests | +| Clarity | 0.10 | Test names clearly describe what they verify | + +**Reference Pattern:** `tests/Effect.spec.ts` (existing test structure and conventions) + +**Complexity**: Medium +**Uncertainty**: Low +**Blockers**: None +**Risks**: None +**Integration Points**: Tests import directly from `../src/wrap-on-method` (internal module) + +--- + +### Step 8: Write WrapOnClass Tests + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 5 +**Parallel with:** Steps 6, 7, 9 -- all four MUST be launched in parallel + +**Goal**: Create unit tests for `WrapOnClass` covering prototype iteration, skip logic, and exclusion key behavior. + +#### Expected Output + +- `tests/WrapOnClass.spec.ts` (NEW) + +#### Success Criteria + +- [X] Test file `tests/WrapOnClass.spec.ts` exists +- [X] Tests cover: wraps all regular prototype methods +- [X] Tests cover: skips `constructor` +- [X] Tests cover: skips getters and setters +- [X] Tests cover: skips non-function prototype values +- [X] Tests cover: skips methods marked with exclusion key via `SetMeta` +- [X] Tests cover: skips methods already wrapped at method level (same exclusion key) +- [X] Tests cover: WRAP_APPLIED_KEY used as default exclusion key +- [X] Tests cover: custom exclusion key propagated to WrapOnMethod +- [X] All new tests pass: `npm run test` + +#### Subtasks + +- [X] Create `tests/WrapOnClass.spec.ts` importing `WrapOnClass` from `../src/wrap-on-class` and related utilities +- [X] Write test: all prototype methods wrapped (3 methods, WrapFn called for each) +- [X] Write test: constructor not wrapped +- [X] Write test: getters/setters not wrapped +- [X] Write test: methods with exclusion key metadata skipped +- [X] Write test: method-level Wrap prevents class-level double-wrapping +- [X] Write test: custom exclusion key works correctly +- [X] Run `npm run test` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `tests/WrapOnClass.spec.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Coverage | 0.30 | Tests cover all 9 success criteria: wraps all methods, skips constructor, skips getters/setters, skips non-functions, skips exclusion key methods, skips already-wrapped, default key, custom key | +| Edge Cases | 0.25 | Getter/setter skip logic, double-wrapping prevention, custom vs default exclusion key | +| Correctness | 0.20 | Tests assert correct wrapping behavior (not just no errors) | +| Isolation | 0.15 | Tests independent; separate class fixtures per test | +| Clarity | 0.10 | Test names clearly describe what they verify | + +**Reference Pattern:** `tests/Effect.spec.ts` (existing test structure and conventions) + +**Complexity**: Medium +**Uncertainty**: Low +**Blockers**: None +**Risks**: None + +--- + +### Step 9: Write Wrap Dispatcher Tests + +**Model:** opus +**Agent:** sdd:developer +**Depends on:** Step 5 +**Parallel with:** Steps 6, 7, 8 -- all four MUST be launched in parallel + +**Goal**: Create unit tests for the public `Wrap` decorator covering class and method dispatch, user-provided WrapFn shape, and sync/async interop. + +#### Expected Output + +- `tests/Wrap.spec.ts` (NEW) + +#### Success Criteria + +- [X] Test file `tests/Wrap.spec.ts` exists +- [X] Tests cover: Wrap applied to a method (decorates single method) +- [X] Tests cover: Wrap applied to a class (decorates all eligible prototype methods) +- [X] Tests cover: user-provided WrapFn receives bound method and WrapContext +- [X] Tests cover: sync method through Wrap works correctly +- [X] Tests cover: async method through Wrap works correctly +- [X] Tests cover: Wrap with exclusion key prevents double-wrapping at class level +- [X] Tests cover: error thrown for invalid decorator context +- [X] All new tests pass: `npm run test` + +#### Subtasks + +- [X] Create `tests/Wrap.spec.ts` importing `Wrap` from `../src/wrap.decorator` (or via index) +- [X] Write test: method-level Wrap decorates and wraps correctly +- [X] Write test: class-level Wrap decorates all prototype methods +- [X] Write test: WrapFn shape verified (receives bound method + WrapContext) +- [X] Write test: sync method wrapped correctly +- [X] Write test: async method wrapped correctly +- [X] Write test: exclusion key prevents double-wrapping +- [X] Write test: invalid context throws Error +- [X] Run `npm run test` + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `tests/Wrap.spec.ts` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Coverage | 0.30 | Tests cover all 8 success criteria: method-level, class-level, WrapFn shape, sync, async, exclusion key, error on invalid context | +| Edge Cases | 0.25 | Invalid decorator context error, exclusion key double-wrapping prevention | +| Correctness | 0.20 | Tests assert dispatch behavior and wrapper execution | +| Isolation | 0.15 | Tests independent | +| Clarity | 0.10 | Test names clearly describe what they verify | + +**Reference Pattern:** `tests/Effect.spec.ts` (existing test structure and conventions) + +**Complexity**: Medium +**Uncertainty**: Low +**Blockers**: None +**Risks**: None + +--- + +### Step 10: Run Full Test + Lint Suite (Verification) + +**Model:** haiku +**Agent:** haiku +**Depends on:** Steps 6, 7, 8, 9 +**Parallel with:** None + +**Goal**: Verify that the entire test suite passes and linting is clean after all parallel work from Steps 6-9 has completed. + +#### Expected Output + +- Full test suite passes (all new Wrap tests + all existing Effect/hook/meta tests) +- Lint passes + +#### Success Criteria + +- [X] `tests/Effect.spec.ts` passes unchanged (Effect interface preserved) +- [X] `tests/OnInvokeHook.spec.ts` passes unchanged +- [X] `tests/OnReturnHook.spec.ts` passes unchanged +- [X] `tests/OnErrorHook.spec.ts` passes unchanged +- [X] `tests/FinallyHook.spec.ts` passes unchanged +- [X] `tests/SetMeta.spec.ts` passes unchanged +- [X] `tests/getParameterNames.spec.ts` passes unchanged +- [X] `tests/WrapOnMethod.spec.ts` passes (new) +- [X] `tests/WrapOnClass.spec.ts` passes (new) +- [X] `tests/Wrap.spec.ts` passes (new) +- [X] `npm run test` exits with code 0, all tests passing +- [X] `npm run lint` exits with code 0 + +#### Subtasks + +- [X] Run `npm run test` and verify all tests pass +- [X] Run `npm run lint` and verify no errors +- [X] If any failures, report the exact error output for debugging + +**Complexity**: Small +**Uncertainty**: Low +**Blockers**: None +#### Verification + +**Level:** ❌ NOT NEEDED +**Rationale:** Binary pass/fail verification. Running `npm run test` and `npm run lint` produces exit codes. No judgment or rubric needed -- either all tests pass and lint is clean, or they do not. + +**Risks**: +- Existing Effect tests may fail if Effect refactoring has subtle behavioral differences -> If failures occur, debug by comparing old and new Effect execution paths +**Integration Points**: This step validates the entire refactoring is behaviorally equivalent + +--- + +### Step 11: Update README Documentation + +**Model:** opus +**Agent:** sdd:tech-writer +**Depends on:** Step 10 +**Parallel with:** None + +**Goal**: Update `README.md` to document the `Wrap` decorator as the foundational primitive, update Quick Start and How It Works sections to showcase Wrap, and add an async Wrap usage example. + +#### Expected Output + +- `README.md` (UPDATED): New Wrap section, updated Quick Start, updated How It Works, async Wrap example, updated API reference table + +#### Success Criteria + +- [X] Quick Start section updated to demonstrate `Wrap` decorator usage +- [X] How It Works section updated to explain `Wrap` as the foundational primitive and `Effect` as a higher-level abstraction built on Wrap +- [X] New "Wrap" section added to Usage with a basic synchronous example +- [X] New "Async Wrap" example added to Usage section +- [X] API Reference table updated: `Wrap` added as a new export with description +- [X] API Reference table updated: `WrapContext` and `WrapFn` types mentioned +- [X] `EffectOnMethod` and `EffectOnClass` no longer referenced in API reference or exports +- [X] All existing sections that still apply (Effect, hooks, metadata, exclusion keys) remain accurate +- [X] `npm run lint` passes +- [X] `npm run test` passes + +#### Subtasks + +- [X] Update Quick Start section in `README.md` to use Wrap decorator example +- [X] Update How It Works section to explain Wrap -> Effect -> hooks layering +- [X] Add Wrap decorator basic usage section (sync example from skill file) +- [X] Add async Wrap usage example (async timer pattern from skill file) +- [X] Update API Reference table: add Wrap, WrapContext, WrapFn; remove EffectOnMethod, EffectOnClass references +- [X] Review all existing sections for accuracy with refactored code +- [X] Run `npm run lint && npm run test` as final verification + +#### Verification + +**Level:** βœ… Single Judge +**Artifact:** `README.md` +**Threshold:** 4.0/5.0 + +**Rubric:** + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Content Accuracy | 0.25 | Wrap examples compile and match actual API; Effect documentation still accurate | +| Completeness | 0.25 | Quick Start updated, How It Works updated, Wrap section added, async Wrap example added, API reference updated | +| Removed References | 0.20 | EffectOnMethod and EffectOnClass no longer referenced in API reference | +| Consistency | 0.15 | Terminology consistent; Wrap described as foundational primitive, Effect as higher-level abstraction | +| Examples Quality | 0.15 | Examples are clear, runnable, and demonstrate key patterns | + +**Reference Pattern:** `README.md` (pre-update state for structural reference) + +**Complexity**: Medium +**Uncertainty**: Low +**Blockers**: None +**Risks**: None + +--- + +## Implementation Summary + +| Step | Phase | Goal | Agent | Key Output | Est. Effort | Dependencies | Parallel With | +|------|-------|------|-------|------------|-------------|--------------|---------------| +| 1 | Foundation | Type definitions | sdd:developer | `src/hook.types.ts` updated | S | None | None | +| 2 | Foundation | WrapOnMethod primitive | sdd:developer | `src/wrap-on-method.ts` (NEW) | M | Step 1 | None | +| 3 | Foundation | WrapOnClass primitive | sdd:developer | `src/wrap-on-class.ts` (NEW) | S | Steps 1, 2 | None | +| 4 | Foundation | Wrap dispatcher | sdd:developer | `src/wrap.decorator.ts` (NEW) | S | Steps 2, 3 | None | +| 5 | Migration | Effect refactored + old files deleted | sdd:developer | `src/effect.decorator.ts` updated, 5 files deleted | L | Step 4 | None | +| 6 | Exports + Tests | Export updates | sdd:developer | `src/index.ts` updated | S | Step 5 | Steps 7, 8, 9 | +| 7 | Exports + Tests | WrapOnMethod tests | sdd:developer | `tests/WrapOnMethod.spec.ts` (NEW) | M | Step 5 | Steps 6, 8, 9 | +| 8 | Exports + Tests | WrapOnClass tests | sdd:developer | `tests/WrapOnClass.spec.ts` (NEW) | M | Step 5 | Steps 6, 7, 9 | +| 9 | Exports + Tests | Wrap dispatcher tests | sdd:developer | `tests/Wrap.spec.ts` (NEW) | M | Step 5 | Steps 6, 7, 8 | +| 10 | Verification | Full test + lint suite | haiku | All tests green, lint clean | S | Steps 6, 7, 8, 9 | None | +| 11 | Documentation | README update | sdd:tech-writer | `README.md` updated | M | Step 10 | None | + +**Total Steps**: 11 +**Critical Path**: Steps 1 -> 2 -> 3 -> 4 -> 5 -> {6,7,8,9} -> 10 -> 11 +**Max Parallelization Depth**: 4 steps simultaneously (Steps 6, 7, 8, 9) +**Merged Steps**: Old test file deletion (originally Step 10) merged into Step 5 to avoid broken intermediate state + +--- + +## Verification Summary + +| Step | Verification Level | Judges | Threshold | Artifacts | +|------|-------------------|--------|-----------|-----------| +| 1 | βœ… Panel (2) | 2 | 4.0/5.0 | `src/hook.types.ts` - WrapContext, WrapFn, HookContext extends WrapContext | +| 2 | βœ… Panel (2) | 2 | 4.0/5.0 | `src/wrap-on-method.ts` - Core method wrapping primitive | +| 3 | βœ… Single | 1 | 4.0/5.0 | `src/wrap-on-class.ts` - Class iteration decorator | +| 4 | βœ… Single | 1 | 4.0/5.0 | `src/wrap.decorator.ts` - Public Wrap dispatcher | +| 5 | βœ… Panel (2) | 2 | 4.0/5.0 | `src/effect.decorator.ts` - Effect refactored + old files deleted | +| 6 | βœ… Single | 1 | 4.0/5.0 | `src/index.ts` - Export updates | +| 7 | βœ… Single | 1 | 4.0/5.0 | `tests/WrapOnMethod.spec.ts` - WrapOnMethod test suite | +| 8 | βœ… Single | 1 | 4.0/5.0 | `tests/WrapOnClass.spec.ts` - WrapOnClass test suite | +| 9 | βœ… Single | 1 | 4.0/5.0 | `tests/Wrap.spec.ts` - Wrap dispatcher test suite | +| 10 | ❌ None | - | - | Full test + lint suite verification (binary pass/fail) | +| 11 | βœ… Single | 1 | 4.0/5.0 | `README.md` - Wrap documentation | + +**Total Evaluations:** 13 +**Implementation Command:** `/implement .specs/tasks/draft/add-wrap-decorator.feature.md` + +--- + +## Risks & Blockers Summary + +### High Priority + +| Risk/Blocker | Impact | Likelihood | Mitigation | +|--------------|--------|------------|------------| +| `this` binding lost in WrapOnMethod | High | Medium | Explicitly bind: `const boundMethod = originalMethod.bind(this)` inside wrapped function; test with class instances in Step 7 | +| Effect behavioral regression after refactoring | High | Low | Copy hook functions (buildArgsObject, attachHooks, resolveHooks, chainAsyncHooks) verbatim; verify via unchanged Effect.spec.ts in Step 10 | +| `as` type assertion count exceeds baseline (15) | Medium | Low | Use `HookContext extends WrapContext` (zero new casts); count assertions after Step 5; budget: wrap-on-method.ts(8) + wrap-on-class.ts(2) + wrap.decorator.ts(3) + effect.decorator.ts(2) = 15 | + +### Medium Priority + +| Risk/Blocker | Impact | Likelihood | Mitigation | +|--------------|--------|------------|------------| +| WrapFn called at decoration time instead of per invocation | High | Low | Factory call must be inside `wrapped = function(this)` closure, not at decoration time | +| `attachHooks` receives pre-bound method + `thisArg` redundancy | Low | Certain | Acceptable: `.apply(thisArg, args)` on pre-bound function is a no-op for `this` (bind takes precedence); preserves exact existing logic | +| Existing Effect.spec.ts tests fail after refactoring | Medium | Low | Debug by comparing old/new execution paths; Effect interface is preserved | + +--- + +## High Complexity/Uncertainty Tasks Requiring Attention + +**Step 5: Refactor Effect to Use Wrap and Delete Old Files** +- Complexity: Large (moves 4 functions, constructs effectWrapFn bridge, deletes 2 source files) +- Uncertainty: Medium (must maintain exact behavioral equivalence for all hook lifecycle scenarios) +- Recommendation: Follow Pattern 3 from skill file verbatim; copy functions without modification; count `as` assertions before proceeding + +**Step 2: Create WrapOnMethod** +- Complexity: Medium +- Uncertainty: Medium (`this` binding and per-invocation factory require careful implementation) +- Recommendation: Follow Pattern 2 from skill file; test thoroughly in Step 7 + +--- + +## Definition of Done (Task Level) + +- [X] All 11 implementation steps completed (Steps 6-9 executed in parallel) +- [X] All acceptance criteria from the task specification verified +- [X] New tests written and passing: WrapOnMethod.spec.ts, WrapOnClass.spec.ts, Wrap.spec.ts +- [X] Existing tests passing: Effect.spec.ts, OnInvokeHook.spec.ts, OnReturnHook.spec.ts, OnErrorHook.spec.ts, FinallyHook.spec.ts, SetMeta.spec.ts, getParameterNames.spec.ts +- [X] Old test files deleted: EffectOnMethod.spec.ts, EffectOnClass.spec.ts, effect-on-method-base.spec.ts +- [X] `as` type assertion count across refactored files <= 15 (baseline) -- actual: 13 +- [X] README documentation updated with Wrap examples +- [X] `npm run lint` passes (exit code 0) +- [X] `npm run test` passes (exit code 0) +- [X] `npm run build` passes (exit code 0) +- [X] No new external dependencies added +- [ ] Code reviewed diff --git a/.specs/tasks/draft/add-wrap-decorator.feature.md b/.specs/tasks/draft/add-wrap-decorator.feature.md deleted file mode 100644 index 77b602a..0000000 --- a/.specs/tasks/draft/add-wrap-decorator.feature.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Add wrap decorator ---- - -## Initial User Prompt - -add @Wrap decorator and refactor existing Effect decorator to use it. This is breaking change, can be backward incompatible, will remove EffectOnMethod and EffectOnClass decorators and can remove rest utils, to make code structure better. But commonly used decorators like Effect and hooks should support same interface and provide same behavior. - -### Requrements - -- Refactor existing EffectOnMethod and EffectOnClass decorators to WrapOnMethod and WrapOnClass decorators. -- Refactor existing Effect decorator to Wrap decorator. -- wrap decorator should work with both sync and async methods. -- Write new Effect decorator thath builds on top of Wrap decorator. -- Update @README.md to include new decorator description, also update quick start and how it works sections to use Wrap decorator instead of Effect and include async wrap example in usage section. -- At the end of the task `npm run lint` and `npm run test` should pass! - -#### Wrap decorator - -Wrap decorator should provide easy way to simplify wrap method to function. -```typescript - -export const Log = () => Wrap((method, context: WrapContext) => { - console.log('method called is', context.propertyKey); - return (...args: unknown[]) => { - console.log('method called with', args); - const result = method(...args); - console.log('method returned', result); - return result; - } -}) -``` - -The `WrapContext` esentially `HookContext` but without args and argsObject. The `HookContext` should be based on `WrapContext` and include additionaly args and argsObject. - -#### Effect decorator - -Effect decorator should simply use Wrap decorator but on top add hooks logic. Avoid write new logic, simply move existing logic from @src/effect-on-method.ts that handles args and hooks to @src/effect.decorator.ts - -## Description - -// Will be filled in future stages by business analyst diff --git a/README.md b/README.md index a070729..1b9352b 100644 --- a/README.md +++ b/README.md @@ -15,26 +15,30 @@ Basic decorator primitives for TypeScript. Writing decorators in TS is hard, thi [Usage](#usage) β€’ [Options](#options) β€’ [API Reference](#api-reference) β€’ -[Advanced Example](#advanced-example) +[Advanced Example](#advanced-example) β€’ +[Wrap Decorator](#wrap-decorator) ## Description -Zero-dependency TypeScript library that provides low-level primitives for creating decorators. Instead of wrestling with property descriptors and prototype traversal, you define a decorator from base hooks: +Zero-dependency TypeScript library that provides low-level primitives for creating decorators. Instead of wrestling with property descriptors and prototype traversal, you use two levels of abstraction: -- `onInvoke` β€” fired before the method runs -- `onReturn` β€” fired after the method succeeds -- `onError` β€” fired when the method throws -- `finally` β€” fired after either success or failure +- **`Wrap`** β€” the foundational primitive that gives you full control over method execution via a higher-order function +- **`Effect`** β€” a higher-level abstraction built on `Wrap` that provides lifecycle hooks: + - `onInvoke` β€” fired before the method runs + - `onReturn` β€” fired after the method succeeds + - `onError` β€” fired when the method throws + - `finally` β€” fired after either success or failure The library handles method wrapping, `this` preservation, async/sync support, parameter name extraction, and metadata management so you can focus on your decorator logic. ### Key Features - **Zero dependencies** β€” tiny footprint, no external packages required -- **Unified decorator** β€” `Effect` works on both classes and methods -- **Full async support** β€” promises are handled automatically with `.then`, `.catch`, and `.finally` +- **Two-level API** β€” `Wrap` for full control, `Effect` for structured lifecycle hooks +- **Unified decorators** β€” both `Wrap` and `Effect` work on classes and methods +- **Full async support** β€” promises are handled automatically - **Pre-built args object** β€” arguments are mapped to parameter names and passed into every hook - **Metadata utilities** β€” `SetMeta`, `getMeta`, and `setMeta` for symbol-keyed method metadata - **TypeScript native** β€” written in TypeScript with full type definitions @@ -47,6 +51,41 @@ npm install base-decorators ## Quick Start +### Using Wrap (full control) + +`Wrap` is the foundational primitive. You receive the original method and a context, and return a replacement function: + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +const Log = () => Wrap((method, context: WrapContext) => { + console.log('decorating', context.propertyKey); + return (...args: unknown[]) => { + console.log('called with', args); + const result = method(...args); + console.log('returned', result); + return result; + }; +}); + +class Calculator { + @Log() + add(a: number, b: number) { + return a + b; + } +} + +const calc = new Calculator(); +calc.add(2, 3); +// logs: "called with [2, 3]" +// logs: "returned 5" +``` + +### Using Effect (lifecycle hooks) + +`Effect` is built on top of `Wrap` and provides structured lifecycle hooks for common patterns: + ```typescript import { Effect } from 'base-decorators'; @@ -66,27 +105,112 @@ calc.add(2, 3); // logs arguments and result ## How It Works -Instead of creating a custom property descriptor wrapper, you can simply compose decorator hooks to get the desired behavior. +The library is organized in three layers, from low-level to high-level: + +``` +Wrap (raw method wrapping β€” full control) + └─ Effect (lifecycle hook orchestration β€” structured callbacks) + └─ OnInvokeHook, OnReturnHook, OnErrorHook, FinallyHook + (convenience decorators β€” single-hook shortcuts) +``` + +**`Wrap`** is the foundational primitive. It accepts a factory function that receives the original method (already bound to `this`) and a `WrapContext`, and returns a replacement function. You control the entire execution flow: + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +const Log = () => Wrap((method, context: WrapContext) => { + return (...args: unknown[]) => { + console.log(`${context.className}.${String(context.propertyKey)} called`); + return method(...args); + }; +}); +``` + +**`Effect`** is built on top of `Wrap`. Instead of writing the full wrapping logic yourself, you provide lifecycle hooks and Effect handles the execution flow: ```typescript import { Effect } from 'base-decorators'; -/** Logs on invoke and return */ const Log = () => Effect({ - onInvoke: ({ args }) => console.log('add called with', args), + onInvoke: ({ args }) => console.log('called with', args), onReturn: ({ result }) => { console.log('result:', result); return result; }, -}) +}); +``` + +**Convenience hooks** are single-purpose decorators built on Effect for the most common patterns: + +```typescript +import { OnInvokeHook } from 'base-decorators'; + +const Log = () => OnInvokeHook(({ args }) => console.log('called with', args)); +``` + +Choose the level that fits your use case: `Wrap` when you need full control over execution, `Effect` when lifecycle hooks suit your pattern, or convenience hooks for simple single-hook scenarios. + +## Usage + +### Wrap decorator + +Use `Wrap` when you need full control over how a method is executed. The wrapper function receives the original method (bound to the correct `this`) and a `WrapContext` with metadata about the decorated method: + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +const Log = () => Wrap((method, context: WrapContext) => { + console.log('decorating', context.propertyKey); + return (...args: unknown[]) => { + console.log('method called with', args); + const result = method(...args); + console.log('method returned', result); + return result; + }; +}); class Calculator { - @Log() add(a: number, b: number) { return a + b; } } + +const calc = new Calculator(); +calc.add(2, 3); +// logs: "method called with [2, 3]" +// logs: "method returned 5" ``` -## Usage +### Async Wrap + +`Wrap` works naturally with async methods. Return an async replacement function to handle promises: + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +const AsyncTimer = () => Wrap((method, context: WrapContext) => { + return async (...args: unknown[]) => { + const start = Date.now(); + const result = await method(...args); + console.log(`${String(context.propertyKey)} took ${Date.now() - start}ms`); + return result; + }; +}); + +class UserService { + @AsyncTimer() + async fetchUser(id: number) { + // async work... + return { id, name: 'Alice' }; + } +} + +const service = new UserService(); +await service.fetchUser(1); +// logs: "fetchUser took 12ms" +``` ### Validate arguments with `OnInvokeHook` @@ -207,7 +331,7 @@ class Worker { ### Class and Method decorators -`Effect` and all hook decorators can be used on both classes and methods out of the box. +`Wrap`, `Effect`, and all hook decorators can be used on both classes and methods out of the box. ```typescript import { Effect } from 'base-decorators'; @@ -344,7 +468,7 @@ For `onReturn`, the context also includes `result`. For `onError`, it includes ` ### Exclusion keys -You can pass an optional `exclusionKey` symbol to `Effect` or to any hook decorator. That prevents the same method from being wrapped twice when both class-level and method-level decorators are used. You can also mark methods to skip wrapping with `@SetMeta(exclusionKey, true)`. +You can pass an optional `exclusionKey` symbol to `Wrap`, `Effect`, or to any hook decorator. That prevents the same method from being wrapped twice when both class-level and method-level decorators are used. You can also mark methods to skip wrapping with `@SetMeta(exclusionKey, true)`. ```typescript const EXCLUDE = Symbol('exclude'); @@ -393,7 +517,8 @@ The factory is called **once per method invocation**, immediately before `onInvo | Export | Type | Description | |--------|------|-------------| -| `Effect` | Decorator | Unified class+method decorator with lifecycle hooks | +| `Wrap` | Decorator | Foundational class+method decorator with raw method wrapping | +| `Effect` | Decorator | Higher-level class+method decorator with lifecycle hooks (built on `Wrap`) | | `SetMeta` | Decorator | Store metadata on methods | | `getMeta` | Function | Retrieve metadata from methods | | `setMeta` | Function | Programmatically set metadata on functions | @@ -401,6 +526,10 @@ The factory is called **once per method invocation**, immediately before `onInvo | `OnReturnHook` | Decorator | Convenience hook for `onReturn` | | `OnErrorHook` | Decorator | Convenience hook for `onError` | | `FinallyHook` | Decorator | Convenience hook for `finally` | +| `WrapContext` | Type | Context passed to `Wrap` wrapper functions (target, propertyKey, parameterNames, className, descriptor) | +| `WrapFn` | Type | Wrapper function signature: `(method, context: WrapContext) => (...args) => R` | +| `HookContext` | Type | Context passed to `Effect` hooks (extends `WrapContext` with args and argsObject) | +| `EffectHooks` | Type | Lifecycle hooks object for `Effect` (onInvoke, onReturn, onError, finally) | ## Advanced Example diff --git a/src/effect-on-method.ts b/src/effect-on-method.ts deleted file mode 100644 index bbeb3d1..0000000 --- a/src/effect-on-method.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { setMeta, SYM_META_PROP } from './set-meta.decorator'; -import type { EffectHooks, HookContext, HooksOrFactory, UnwrapPromise } from './hook.types'; -import { getParameterNames } from './getParameterNames'; - -/** - * Symbol sentinel set on every function wrapped by {@link EffectOnMethod}. - * - * Used by `EffectOnClass` to detect methods that have already been wrapped - * at the method level, preventing double-wrapping when both class-level - * and method-level decorators are applied. - */ -export const EFFECT_APPLIED_KEY: unique symbol = Symbol('effectApplied'); - -/** - * Method decorator factory that wraps `descriptor.value` with lifecycle hooks. - * - * The wrapped function preserves `this` context and transparently handles - * both sync and async (Promise-returning) methods. After wrapping, the - * {@link EFFECT_APPLIED_KEY} sentinel is set on the new function via - * `setMeta`, and any existing `_symMeta` metadata from the original - * function is copied to the wrapper. - * - * @typeParam R - The return type of the decorated method - * @param hooksOrFactory - Lifecycle callbacks (all optional) or a factory - * function that receives a {@link HookContext} and - * returns hooks. The factory is called once per - * method invocation, before any hooks fire. - * @param exclusionKey - Optional symbol used to mark the wrapped method. When - * provided, this key is set instead of the default - * {@link EFFECT_APPLIED_KEY}. This allows different - * Effect-based decorators (e.g. `@Log`, `@Metrics`) to - * use independent markers that do not interfere with - * each other during class-level decoration. - * @returns A standard `MethodDecorator` - * - * @example - * ```ts - * class Service { - * \@EffectOnMethod({ - * onInvoke: ({ args, propertyKey }) => console.log('called', propertyKey, args), - * onReturn: ({ result }) => { console.log('done'); return result; }, - * onError: ({ propertyKey, error }) => { console.error(propertyKey, 'failed:', error); throw error; }, - * }) - * doWork(input: string) { return input.toUpperCase(); } - * } - * ``` - */ -export const EffectOnMethod = ( - hooksOrFactory: HooksOrFactory, - exclusionKey: symbol = EFFECT_APPLIED_KEY, -): MethodDecorator => { - return ( - _target: object, - propertyKey: string | symbol, - descriptor: PropertyDescriptor, - ): PropertyDescriptor => { - const originalMethod = descriptor.value as (...args: unknown[]) => unknown; - - // Extract parameter names at decoration time (once, not per-call) - const parameterNames = getParameterNames(originalMethod); - - const wrapped = wrapFunction( - originalMethod, - parameterNames, - propertyKey, - descriptor, - hooksOrFactory, - ); - - copySymMeta(originalMethod, wrapped); - - descriptor.value = wrapped; - - setMeta(exclusionKey, true, descriptor); - - return descriptor; - }; -}; - - - -/** - * Builds an object mapping parameter names to their values. - * - * Creates a record where keys are parameter names and values are the - * corresponding argument values passed to the function. - * - * @param parameterNames - Array of parameter names - * @param args - Array of argument values - * @returns Object mapping parameter names to values, or undefined when empty - * - * @example - * buildArgsObject(['id', 'name'], [1, 'John']) - * // Returns: { id: 1, name: 'John' } - * - * @internal - */ -export const buildArgsObject = ( - parameterNames: string[], - args: unknown[], -): Record | undefined => { - if (args.length === 0 && parameterNames.length === 0) { - return undefined; - } - - const argsObject: Record = {}; - - parameterNames.forEach((paramName, index) => { - if (index < args.length) { - argsObject[paramName] = args[index]; - } - }); - - return argsObject; -}; - -/** - * Builds the per-method wrapper used by {@link EffectOnMethod}: constructs - * {@link HookContext}, resolves hooks, and wires `onInvoke` plus execution. - */ -export const wrapFunction = ( - originalMethod: (...args: unknown[]) => unknown, - parameterNames: string[], - propertyKey: string | symbol, - descriptor: PropertyDescriptor, - hooksOrFactory: HooksOrFactory, -): ((this: object, ...args: unknown[]) => unknown) => - function (this: object, ...args: unknown[]): unknown { - const argsObject = buildArgsObject(parameterNames, args); - const className = (this.constructor as { name: string }).name ?? ''; - - const context: HookContext = { - argsObject, - args, - target: this, - propertyKey, - descriptor, - parameterNames, - className, - }; - - const hooks = resolveHooks(hooksOrFactory, context); - - const executeMethod = attachHooks(originalMethod, this, args, context, hooks); - - if (hooks.onInvoke) { - const invokeResult = hooks.onInvoke(context); - - if (invokeResult instanceof Promise) { - return invokeResult.then(executeMethod); - } - } - - return executeMethod(); - }; - -/** - * Returns a thunk that runs the original method and applies sync/async lifecycle hooks. - * - * Kept as a thunk so async `onInvoke` can defer execution via `.then()`. - * `finally` is applied inline on sync paths to avoid double-calling when - * `onReturn` or `onError` throw. - */ -export const attachHooks = ( - originalMethod: (...args: unknown[]) => unknown, - thisArg: object, - args: unknown[], - context: HookContext, - hooks: EffectHooks, -): (() => unknown) => () => { - try { - const result = originalMethod.apply(thisArg, args); - - if (result instanceof Promise) { - return chainAsyncHooks(result, context, hooks); - } - - try { - return hooks.onReturn - ? hooks.onReturn({ ...context, result: result as UnwrapPromise }) - : result; - } finally { - hooks.finally?.(context); - } - } catch (error: unknown) { - try { - if (hooks.onError) { - return hooks.onError({ ...context, error }); - } - - throw error; - } finally { - hooks.finally?.(context); - } - } -}; - - - -/** - * Copies the `_symMeta` Map from the original function to a new function. - * - * When `EffectOnMethod` replaces `descriptor.value` with a wrapper, - * any metadata previously set on the original function (e.g. via `@SetMeta` - * or `@NoLog`) must survive on the new wrapper so downstream consumers - * (like `EffectOnClass`) can still read it. - */ -const copySymMeta = (source: Function, target: Function): void => { - const sourceRecord = source as unknown as Record; - const sourceMap = sourceRecord[SYM_META_PROP] as - | Map - | undefined; - - if (!sourceMap || sourceMap.size === 0) return; - - const targetRecord = target as unknown as Record; - - if (!targetRecord[SYM_META_PROP]) { - Object.defineProperty(target, SYM_META_PROP, { - value: new Map(), - writable: false, - enumerable: false, - configurable: false, - }); - } - - const targetMap = targetRecord[SYM_META_PROP] as Map; - sourceMap.forEach((value, key) => targetMap.set(key, value)); -}; - -/** - * Resolves hooks from a static object or factory function. - * - * When `hooksOrFactory` is a function, it is called with the provided - * context to produce the hooks. Otherwise, the static hooks are returned. - */ -const resolveHooks = ( - hooksOrFactory: HooksOrFactory, - context: HookContext, -): EffectHooks => { - if (typeof hooksOrFactory === 'function') { - return hooksOrFactory(context); - } - return hooksOrFactory; -}; - -/** - * Applies lifecycle hooks (onReturn, onError, finally) to an async method result. - * - * Uses async/await with try/catch/finally so that onReturn fires after - * resolution, onError fires after rejection, and finally always fires last. - */ -const chainAsyncHooks = async ( - promise: Promise, - context: HookContext, - hooks: EffectHooks, -): Promise => { - try { - const value = await promise; - - return hooks.onReturn - ? await hooks.onReturn({ ...context, result: value as UnwrapPromise }) - : value; - } catch (error: unknown) { - if (hooks.onError) { - return await hooks.onError({ ...context, error }); - } - - throw error; - } finally { - if (hooks.finally) { - await hooks.finally(context); - } - } -}; - diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index e4d34a0..f7fd3cc 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -1,32 +1,37 @@ -import { EffectOnMethod } from './effect-on-method'; -import { EffectOnClass } from './effect-on-class'; -import type { HooksOrFactory } from './hook.types'; +import { Wrap } from './wrap.decorator'; +import type { + EffectHooks, + HookContext, + HooksOrFactory, + UnwrapPromise, + WrapContext, + WrapFn, +} from './hook.types'; /** * Creates a decorator that can be applied to either a class or a method. * - * When applied to a **class** (receives 1 argument -- the constructor), - * delegates to {@link EffectOnClass} which wraps every eligible prototype - * method with the provided lifecycle hooks, skipping methods already - * marked with `EFFECT_APPLIED_KEY` or `exclusionKey`. + * Internally constructs an `effectWrapFn` that implements lifecycle hooks + * (onInvoke, onReturn, onError, finally) and delegates all class/method + * dispatch logic to {@link Wrap}. * - * When applied to a **method** (receives 3 arguments -- target, propertyKey, - * descriptor), delegates to {@link EffectOnMethod} which wraps that single - * method with the provided lifecycle hooks and marks it with `exclusionKey` - * (or `EFFECT_APPLIED_KEY` if none provided) to prevent double-wrapping - * by a class-level decorator. + * When applied to a **class**, wraps every eligible prototype method with + * the provided lifecycle hooks (via Wrap -> WrapOnClass). * - * Throws an `Error` if invoked in any other context (e.g. `propertyKey` is - * present but `descriptor` is `undefined`). + * When applied to a **method**, wraps that single method with the provided + * lifecycle hooks (via Wrap -> WrapOnMethod). * * @typeParam R - The return type expected from lifecycle hooks - * @param hooks - Lifecycle callbacks forwarded to the underlying decorator - * @param exclusionKey - Optional symbol used to mark the wrapped method. When - * provided, this key is set instead of the default - * {@link EFFECT_APPLIED_KEY}. This allows different - * Effect-based decorators (e.g. `@Log`, `@Metrics`) to - * use independent markers that do not interfere with - * each other during class-level decoration. + * @param hooks - Lifecycle callbacks (all optional) or a factory + * function that receives a {@link HookContext} and + * returns hooks. The factory is called once per + * method invocation, before any hooks fire. + * @param exclusionKey - Optional symbol used to mark the wrapped method. When + * provided, this key is set instead of the default + * `WRAP_APPLIED_KEY`. This allows different + * Effect-based decorators (e.g. `@Log`, `@Metrics`) to + * use independent markers that do not interfere with + * each other during class-level decoration. * @returns A decorator usable on both classes and methods * * @example @@ -49,25 +54,158 @@ export const Effect = ( hooks: HooksOrFactory, exclusionKey?: symbol, ): ClassDecorator & MethodDecorator => { - const classDecorator = EffectOnClass(hooks, exclusionKey); - const methodDecorator = EffectOnMethod(hooks, exclusionKey); - - return (( - target: Function | object, - propertyKey?: string | symbol, - descriptor?: PropertyDescriptor, - ): Function | PropertyDescriptor | void => { - // Class decorator: receives 1 argument (the constructor) - if (propertyKey === undefined) { - classDecorator(target as Function); - return target as Function; + const effectWrapFn: WrapFn = ( + boundMethod: (...args: unknown[]) => unknown, + wrapContext: WrapContext, + ) => { + return (...args: unknown[]): unknown => { + const argsObject = buildArgsObject(wrapContext.parameterNames, args); + + const hookContext: HookContext = { ...wrapContext, args, argsObject }; + + const resolvedHooks = resolveHooks(hooks, hookContext); + + const executeMethod = attachHooks( + boundMethod, + wrapContext.target, + args, + hookContext, + resolvedHooks, + ); + + if (resolvedHooks.onInvoke) { + const invokeResult = resolvedHooks.onInvoke(hookContext); + + if (invokeResult instanceof Promise) { + return invokeResult.then(executeMethod); + } + } + + return executeMethod(); + }; + }; + + return Wrap(effectWrapFn, exclusionKey); +}; + +/** + * Builds an object mapping parameter names to their values. + * + * Creates a record where keys are parameter names and values are the + * corresponding argument values passed to the function. + * + * @param parameterNames - Array of parameter names + * @param args - Array of argument values + * @returns Object mapping parameter names to values, or undefined when empty + * + * @example + * buildArgsObject(['id', 'name'], [1, 'John']) + * // Returns: { id: 1, name: 'John' } + * + * @internal + */ +export const buildArgsObject = ( + parameterNames: string[], + args: unknown[], +): Record | undefined => { + if (args.length === 0 && parameterNames.length === 0) { + return undefined; + } + + const argsObject: Record = {}; + + parameterNames.forEach((paramName, index) => { + if (index < args.length) { + argsObject[paramName] = args[index]; } + }); - // Method decorator: receives 3 arguments (target, propertyKey, descriptor) - if (descriptor !== undefined) { - return methodDecorator(target, propertyKey, descriptor); + return argsObject; +}; + +/** + * Returns a thunk that runs the original method and applies sync/async lifecycle hooks. + * + * Kept as a thunk so async `onInvoke` can defer execution via `.then()`. + * `finally` is applied inline on sync paths to avoid double-calling when + * `onReturn` or `onError` throw. + */ +const attachHooks = ( + originalMethod: (...args: unknown[]) => unknown, + thisArg: object, + args: unknown[], + context: HookContext, + hooks: EffectHooks, +): (() => unknown) => () => { + try { + const result = originalMethod.apply(thisArg, args); + + if (result instanceof Promise) { + return chainAsyncHooks(result, context, hooks); + } + + try { + return hooks.onReturn + ? hooks.onReturn({ ...context, result: result as UnwrapPromise }) + : result; + } finally { + hooks.finally?.(context); + } + } catch (error: unknown) { + try { + if (hooks.onError) { + return hooks.onError({ ...context, error }); + } + + throw error; + } finally { + hooks.finally?.(context); + } + } +}; + +/** + * Resolves hooks from a static object or factory function. + * + * When `hooksOrFactory` is a function, it is called with the provided + * context to produce the hooks. Otherwise, the static hooks are returned. + */ +const resolveHooks = ( + hooksOrFactory: HooksOrFactory, + context: HookContext, +): EffectHooks => { + if (typeof hooksOrFactory === 'function') { + return hooksOrFactory(context); + } + return hooksOrFactory; +}; + +/** + * Applies lifecycle hooks (onReturn, onError, finally) to an async method result. + * + * Uses async/await with try/catch/finally so that onReturn fires after + * resolution, onError fires after rejection, and finally always fires last. + */ +const chainAsyncHooks = async ( + promise: Promise, + context: HookContext, + hooks: EffectHooks, +): Promise => { + try { + const value = await promise; + + return hooks.onReturn + ? await hooks.onReturn({ ...context, result: value as UnwrapPromise }) + : value; + } catch (error: unknown) { + if (hooks.onError) { + return await hooks.onError({ ...context, error }); } - throw new Error('Effect decorator can only be applied to classes or methods'); - }) as ClassDecorator & MethodDecorator; + throw error; + } finally { + if (hooks.finally) { + await hooks.finally(context); + } + } }; diff --git a/src/hook.types.ts b/src/hook.types.ts index dcb6593..4a10dce 100644 --- a/src/hook.types.ts +++ b/src/hook.types.ts @@ -2,18 +2,14 @@ export type HookArgs = Record | undefined; /** - * Shared context passed to every lifecycle hook. + * Decoration-time and runtime context available to every wrapper. * - * Contains the common fields available at every lifecycle point: - * the pre-built args object, the `this` target, the property key, - * the property descriptor, extracted parameter names, and the - * runtime class name. + * Contains the fields that are known at decoration time (propertyKey, + * parameterNames, descriptor) plus fields resolved at runtime (target, + * className). Does NOT include per-call argument data -- that is added + * by {@link HookContext} for lifecycle-hook consumers. */ -export interface HookContext { - /** Raw arguments array passed to the method. */ - args: unknown[]; - /** Pre-built args object mapping parameter names to their values. */ - argsObject: HookArgs; +export interface WrapContext { /** The `this` target object (class instance). */ target: object; /** The property key of the decorated method. */ @@ -26,6 +22,34 @@ export interface HookContext { descriptor: PropertyDescriptor; } +/** + * Factory function accepted by the Wrap decorator. + * + * Receives the original (this-bound) method and a {@link WrapContext}, + * and returns a replacement function that is called with the actual + * arguments at invocation time. + * + * @typeParam R - The return type produced by the replacement function + */ +export type WrapFn = ( + method: (...args: unknown[]) => unknown, + context: WrapContext, +) => (...args: unknown[]) => R; + +/** + * Shared context passed to every lifecycle hook. + * + * Extends {@link WrapContext} with per-call argument data: the raw + * arguments array and the pre-built args object mapping parameter + * names to their values. + */ +export interface HookContext extends WrapContext { + /** Raw arguments array passed to the method. */ + args: unknown[]; + /** Pre-built args object mapping parameter names to their values. */ + argsObject: HookArgs; +} + /** Extracts the resolved type from a Promise, or returns the type itself. */ export type UnwrapPromise = T extends Promise ? U : T; diff --git a/src/index.ts b/src/index.ts index 5e582fa..b964bc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export * from './effect-on-method'; -export * from './effect-on-class'; +// Internal (not exported): wrap-on-method.ts, wrap-on-class.ts +export * from './wrap.decorator'; export * from './effect.decorator'; export type * from './hook.types'; diff --git a/src/effect-on-class.ts b/src/wrap-on-class.ts similarity index 65% rename from src/effect-on-class.ts rename to src/wrap-on-class.ts index 8bd1046..e7c2bce 100644 --- a/src/effect-on-class.ts +++ b/src/wrap-on-class.ts @@ -1,57 +1,59 @@ /** - * Class-level decorator that applies lifecycle hooks to all prototype methods. + * Class-level decorator that applies a {@link WrapFn} to all prototype methods. * * Iterates `Object.getOwnPropertyNames(target.prototype)`, skipping the * constructor, non-function values, getters/setters, methods already wrapped - * by {@link EffectOnMethod} (detected via {@link EFFECT_APPLIED_KEY}), and + * by {@link WrapOnMethod} (detected via {@link WRAP_APPLIED_KEY}), and * methods excluded via an optional `exclusionKey` symbol. * - * This module is logger-agnostic and contains zero imports from `@nestjs/common`. - * - * @module effect-on-class + * @module wrap-on-class */ import { getMeta } from './set-meta.decorator'; -import type { HooksOrFactory } from './hook.types'; -import { EffectOnMethod, EFFECT_APPLIED_KEY } from './effect-on-method'; +import type { WrapFn } from './hook.types'; +import { WrapOnMethod, WRAP_APPLIED_KEY } from './wrap-on-method'; /** * Class decorator factory that wraps every eligible prototype method with - * lifecycle hooks via {@link EffectOnMethod}. + * a user-provided {@link WrapFn} via {@link WrapOnMethod}. * * Skipped members: * - `constructor` * - Non-function prototype values * - Getters and setters (only plain `descriptor.value` functions are wrapped) * - Methods marked with `exclusionKey` metadata (double-wrap prevention and - * explicit exclusion via e.g. `@NoLog()`) + * explicit exclusion via e.g. `@SetMeta(key, true)`) * - * @typeParam R - The return type expected from lifecycle hooks - * @param hooks - Lifecycle callbacks forwarded to {@link EffectOnMethod} + * @typeParam R - The return type expected from the wrapped methods + * @param wrapFn - Factory forwarded to {@link WrapOnMethod} for each + * eligible method * @param exclusionKey - Symbol used to detect already-decorated and excluded - * methods. Defaults to {@link EFFECT_APPLIED_KEY}. Pass a + * methods. Defaults to {@link WRAP_APPLIED_KEY}. Pass a * custom symbol to isolate this decorator from other - * Effect-based decorators. + * Wrap-based decorators. * @returns A standard `ClassDecorator` * * @example * ```ts - * const SKIP = Symbol('skip'); + * const LOG_KEY = Symbol('log'); * - * \@EffectOnClass({ onReturn: ({ propertyKey, result }) => { console.log(propertyKey); return result; } }, SKIP) + * \@WrapOnClass((method, ctx) => (...args) => { + * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); + * return method(...args); + * }, LOG_KEY) * class Service { * doWork() { return 42; } * - * \@SetMeta(SKIP, true) + * \@SetMeta(LOG_KEY, true) * internal() { return 'skipped'; } * } * ``` */ -export const EffectOnClass = ( - hooks: HooksOrFactory, - exclusionKey: symbol = EFFECT_APPLIED_KEY, +export const WrapOnClass = ( + wrapFn: WrapFn, + exclusionKey: symbol = WRAP_APPLIED_KEY, ): ClassDecorator => { - const methodDecorator = EffectOnMethod(hooks, exclusionKey); + const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); return (target: Function): void => { const prototype = target.prototype as Record; @@ -59,13 +61,13 @@ export const EffectOnClass = ( for (const propertyName of propertyNames) { if (propertyName === 'constructor') { - continue + continue; } const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName); if ( !descriptor - || !isPlainMethod(descriptor) + || !isPlainMethod(descriptor) || shouldSkipMethod(descriptor, exclusionKey) ) { continue; @@ -77,7 +79,6 @@ export const EffectOnClass = ( }; }; - /** * Determines whether a property descriptor represents a plain method. * @@ -93,9 +94,9 @@ const isPlainMethod = (descriptor: PropertyDescriptor): boolean => { * Determines whether a method should be skipped by the class decorator. * * Uses the provided `exclusionKey` to check for metadata on the method. - * When `EffectOnMethod` wraps a method it marks it with the same key, + * When `WrapOnMethod` wraps a method it marks it with the same key, * so this single check handles both double-wrap prevention (method already - * decorated by this decorator type) and explicit exclusion (e.g. `@NoLog()`). + * decorated by this decorator type) and explicit exclusion (e.g. `@SetMeta(key, true)`). */ const shouldSkipMethod = ( descriptor: PropertyDescriptor, diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts new file mode 100644 index 0000000..e620daa --- /dev/null +++ b/src/wrap-on-method.ts @@ -0,0 +1,117 @@ +import { setMeta, SYM_META_PROP } from './set-meta.decorator'; +import { getParameterNames } from './getParameterNames'; +import type { WrapFn, WrapContext } from './hook.types'; + +/** + * Symbol sentinel set on every function wrapped by {@link WrapOnMethod}. + * + * Used by `WrapOnClass` to detect methods that have already been wrapped + * at the method level, preventing double-wrapping when both class-level + * and method-level decorators are applied. + */ +export const WRAP_APPLIED_KEY: unique symbol = Symbol('wrapApplied'); + +/** + * Core method decorator factory that wraps `descriptor.value` using a + * user-provided {@link WrapFn} factory. + * + * The wrapped function preserves `this` context by binding the original + * method to the runtime `this` on every invocation. After wrapping, the + * exclusion key sentinel is set on the descriptor via `setMeta`, and any + * existing `_symMeta` metadata from the original function is copied to + * the wrapper. + * + * @typeParam R - The return type of the decorated method + * @param wrapFn - Factory called per invocation with the `this`-bound + * original method and a {@link WrapContext}. Returns the + * replacement function that receives the actual arguments. + * @param exclusionKey - Optional symbol used to mark the wrapped method. When + * provided, this key is set instead of the default + * {@link WRAP_APPLIED_KEY}. This allows different + * Wrap-based decorators to use independent markers that + * do not interfere with each other during class-level + * decoration. + * @returns A standard `MethodDecorator` + * + * @example + * ```ts + * class Service { + * \@WrapOnMethod((method, ctx) => (...args) => { + * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); + * return method(...args); + * }) + * doWork(input: string) { return input.toUpperCase(); } + * } + * ``` + */ +export const WrapOnMethod = ( + wrapFn: WrapFn, + exclusionKey: symbol = WRAP_APPLIED_KEY, +): MethodDecorator => { + return ( + _target: object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + // Extract parameter names at decoration time (once, not per-call) + const parameterNames = getParameterNames(originalMethod); + + const wrapped = function (this: object, ...args: unknown[]): unknown { + const boundMethod = originalMethod.bind(this); + const className = (this.constructor as { name: string }).name ?? ''; + + const wrapContext: WrapContext = { + target: this, + propertyKey, + parameterNames, + className, + descriptor, + }; + + const innerFn = wrapFn(boundMethod, wrapContext); + + return innerFn(...args); + }; + + copySymMeta(originalMethod, wrapped); + + descriptor.value = wrapped; + + setMeta(exclusionKey, true, descriptor); + + return descriptor; + }; +}; + +/** + * Copies the `_symMeta` Map from the original function to a new function. + * + * When `WrapOnMethod` replaces `descriptor.value` with a wrapper, + * any metadata previously set on the original function (e.g. via `@SetMeta`) + * must survive on the new wrapper so downstream consumers + * (like `WrapOnClass`) can still read it. + */ +const copySymMeta = (source: Function, target: Function): void => { + const sourceRecord = source as unknown as Record; + const sourceMap = sourceRecord[SYM_META_PROP] as + | Map + | undefined; + + if (!sourceMap || sourceMap.size === 0) return; + + const targetRecord = target as unknown as Record; + + if (!targetRecord[SYM_META_PROP]) { + Object.defineProperty(target, SYM_META_PROP, { + value: new Map(), + writable: false, + enumerable: false, + configurable: false, + }); + } + + const targetMap = targetRecord[SYM_META_PROP] as Map; + sourceMap.forEach((value, key) => targetMap.set(key, value)); +}; diff --git a/src/wrap.decorator.ts b/src/wrap.decorator.ts new file mode 100644 index 0000000..6f9d871 --- /dev/null +++ b/src/wrap.decorator.ts @@ -0,0 +1,83 @@ +import { WrapOnClass } from './wrap-on-class'; +import { WrapOnMethod } from './wrap-on-method'; +import type { WrapFn } from './hook.types'; + +/** + * Creates a decorator that can be applied to either a class or a method. + * + * When applied to a **class** (receives 1 argument -- the constructor), + * delegates to {@link WrapOnClass} which wraps every eligible prototype + * method with the provided wrapper function, skipping methods already + * marked with `WRAP_APPLIED_KEY` or `exclusionKey`. + * + * When applied to a **method** (receives 3 arguments -- target, propertyKey, + * descriptor), delegates to {@link WrapOnMethod} which wraps that single + * method with the provided wrapper function and marks it with `exclusionKey` + * (or `WRAP_APPLIED_KEY` if none provided) to prevent double-wrapping + * by a class-level decorator. + * + * Throws an `Error` if invoked in any other context (e.g. `propertyKey` is + * present but `descriptor` is `undefined`). + * + * @typeParam R - The return type expected from the wrapper function + * @param wrapFn - Factory called per invocation with the `this`-bound + * original method and a {@link WrapContext}. Returns the + * replacement function that receives the actual arguments. + * @param exclusionKey - Optional symbol used to mark the wrapped method. When + * provided, this key is set instead of the default + * `WRAP_APPLIED_KEY`. This allows different + * Wrap-based decorators (e.g. `@Log`, `@Timer`) to + * use independent markers that do not interfere with + * each other during class-level decoration. + * @returns A decorator usable on both classes and methods + * + * @example + * ```ts + * // Method-level usage + * class Service { + * \@Wrap((method, ctx) => (...args) => { + * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); + * return method(...args); + * }) + * doWork() { return 42; } + * } + * + * // Class-level usage + * \@Wrap((method, ctx) => (...args) => { + * console.log(`${String(ctx.propertyKey)} called`); + * return method(...args); + * }) + * class AnotherService { + * methodA() { return 'a'; } + * methodB() { return 'b'; } + * } + * ``` + */ +export const Wrap = ( + wrapFn: WrapFn, + exclusionKey?: symbol, +): ClassDecorator & MethodDecorator => { + const classDecorator = WrapOnClass(wrapFn, exclusionKey); + const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); + + return (( + target: Function | object, + propertyKey?: string | symbol, + descriptor?: PropertyDescriptor, + ): Function | PropertyDescriptor | void => { + // Class decorator: receives 1 argument (the constructor) + if (propertyKey === undefined) { + classDecorator(target as Function); + return target as Function; + } + + // Method decorator: receives 3 arguments (target, propertyKey, descriptor) + if (descriptor !== undefined) { + return methodDecorator(target, propertyKey, descriptor); + } + + throw new Error( + 'Wrap decorator can only be applied to classes or methods', + ); + }) as ClassDecorator & MethodDecorator; +}; diff --git a/tests/Effect.spec.ts b/tests/Effect.spec.ts index ff56e0d..9ff5636 100644 --- a/tests/Effect.spec.ts +++ b/tests/Effect.spec.ts @@ -313,7 +313,7 @@ describe('Effect', () => { // This cannot happen with normal TypeScript decorator application but tests the guard expect(() => { (decorator as Function)({}, 'someProperty', undefined); - }).toThrow('Effect decorator can only be applied to classes or methods'); + }).toThrow('Wrap decorator can only be applied to classes or methods'); }); }); }); diff --git a/tests/EffectOnClass.spec.ts b/tests/EffectOnClass.spec.ts deleted file mode 100644 index 5456819..0000000 --- a/tests/EffectOnClass.spec.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { EffectOnClass } from '../src/effect-on-class'; -import { - EffectOnMethod, - EFFECT_APPLIED_KEY, -} from '../src/effect-on-method'; -import { setMeta, getMeta, SetMeta } from '../src/set-meta.decorator'; -import type { EffectHooks } from '../src/set-meta.decorator'; - -describe('EffectOnClass', () => { - describe('wraps all prototype methods with hooks', () => { - it('should wrap all 3 methods and fire hooks for each', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn }) - class TestService { - methodA() { - return 'a'; - } - - methodB() { - return 'b'; - } - - methodC() { - return 'c'; - } - } - - const service = new TestService(); - expect(service.methodA()).toBe('a'); - expect(service.methodB()).toBe('b'); - expect(service.methodC()).toBe('c'); - - expect(onReturn).toHaveBeenCalledTimes(3); - }); - }); - - describe('constructor is never wrapped', () => { - it('should not fire hooks during construction', () => { - const onInvoke = vi.fn(); - - @EffectOnClass({ onInvoke }) - class TestService { - value: number; - - constructor() { - this.value = 42; - } - - doWork() { - return this.value; - } - } - - const service = new TestService(); - expect(service.value).toBe(42); - - // onInvoke should not have been called during construction - expect(onInvoke).not.toHaveBeenCalled(); - - // But should fire when calling a method - service.doWork(); - expect(onInvoke).toHaveBeenCalledOnce(); - }); - }); - - describe('methods with EFFECT_APPLIED_KEY are skipped', () => { - it('should skip methods already decorated with EffectOnMethod', () => { - const classOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const methodOnReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn: classOnReturn }) - class TestService { - @EffectOnMethod({ onReturn: methodOnReturn }) - alreadyWrapped() { - return 'wrapped'; - } - - notWrapped() { - return 'not-wrapped'; - } - } - - const service = new TestService(); - service.alreadyWrapped(); - service.notWrapped(); - - // Method-level hook fires once for the already-wrapped method - expect(methodOnReturn).toHaveBeenCalledOnce(); - // Class-level hook fires only for notWrapped (skipped alreadyWrapped) - expect(classOnReturn).toHaveBeenCalledOnce(); - }); - - it('should skip methods with EFFECT_APPLIED_KEY set via setMeta', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - class TestService { - preMarked() { - return 'pre-marked'; - } - - normal() { - return 'normal'; - } - } - - // Pre-set the EFFECT_APPLIED_KEY on preMarked - const preMarkedDescriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'preMarked', - )!; - setMeta(EFFECT_APPLIED_KEY, true, preMarkedDescriptor); - - // Apply class decorator - EffectOnClass({ onReturn })(TestService); - - const service = new TestService(); - service.preMarked(); - service.normal(); - - // onReturn should fire only for 'normal', not 'preMarked' - expect(onReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('methods with exclusionKey metadata are skipped', () => { - it('should skip methods marked with the provided exclusionKey', () => { - const EXCLUSION_KEY = Symbol('noEffect'); - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn }, EXCLUSION_KEY) - class TestService { - @SetMeta(EXCLUSION_KEY, true) - excluded() { - return 'excluded'; - } - - included() { - return 'included'; - } - } - - const service = new TestService(); - service.excluded(); - service.included(); - - // onReturn fires only for 'included' - expect(onReturn).toHaveBeenCalledOnce(); - }); - - it('should not skip any methods when exclusionKey is not provided', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - const SOME_KEY = Symbol('someKey'); - - @EffectOnClass({ onReturn }) - class TestService { - @SetMeta(SOME_KEY, true) - markedWithSomeKey() { - return 'marked'; - } - - normal() { - return 'normal'; - } - } - - const service = new TestService(); - service.markedWithSomeKey(); - service.normal(); - - // Both methods wrapped since no exclusionKey was passed - expect(onReturn).toHaveBeenCalledTimes(2); - }); - }); - - describe('method-level decorator prevents class-level double-wrap', () => { - it('should skip method marked with exclusionKey even when also decorated at method level', () => { - const EXCLUSION_KEY = Symbol('noEffect'); - const classOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const methodOnReturn = vi.fn(({ result }: { result: unknown }) => result); - - // Method-level decorator wins over class-level decorator - @EffectOnClass({ onReturn: classOnReturn }, EXCLUSION_KEY) - class TestService { - @EffectOnMethod({ onReturn: methodOnReturn }) - @SetMeta(EXCLUSION_KEY, true) - methodLevelWins() { - return 'method-level'; - } - - normal() { - return 'normal'; - } - } - - const service = new TestService(); - service.methodLevelWins(); - service.normal(); - - // Method-level hook fires for methodLevelWins - expect(methodOnReturn).toHaveBeenCalledOnce(); - // Class-level hook fires only for normal - // (methodLevelWins is skipped because EXCLUSION_KEY metadata is set) - expect(classOnReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('getters and setters are not wrapped', () => { - it('should skip getters and setters', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn }) - class TestService { - private _value = 10; - - get value() { - return this._value; - } - - set value(val: number) { - this._value = val; - } - - doWork() { - return this._value * 2; - } - } - - const service = new TestService(); - - // Access getter - const val = service.value; - expect(val).toBe(10); - - // Use setter - service.value = 20; - expect(service.value).toBe(20); - - // Call method - service.doWork(); - - // onReturn should fire only for doWork, not getter/setter - expect(onReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('non-function properties are not wrapped', () => { - it('should skip non-function prototype properties', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - class TestService { - doWork() { - return 'work'; - } - } - - // Define a non-function property on the prototype - Object.defineProperty(TestService.prototype, 'staticData', { - value: 'not-a-function', - writable: true, - enumerable: true, - configurable: true, - }); - - EffectOnClass({ onReturn })(TestService); - - const service = new TestService(); - service.doWork(); - - // onReturn fires only for doWork - expect(onReturn).toHaveBeenCalledOnce(); - // Non-function property is untouched - expect((service as unknown as Record).staticData).toBe( - 'not-a-function', - ); - }); - }); - - describe('class with no methods (only constructor)', () => { - it('should not throw errors', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - expect(() => { - @EffectOnClass({ onReturn }) - class EmptyService { - value = 1; - } - - const svc = new EmptyService(); - expect(svc.value).toBe(1); - }).not.toThrow(); - - expect(onReturn).not.toHaveBeenCalled(); - }); - }); - - describe('inherited prototype methods are wrapped', () => { - it('should wrap methods from parent class prototype when on subclass prototype', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - class ParentService { - parentMethod() { - return 'parent'; - } - } - - // NOTE: Object.getOwnPropertyNames only returns own properties. - // If the subclass inherits from the parent, the parent method is on - // ParentService.prototype, not SubService.prototype. - // EffectOnClass uses getOwnPropertyNames, so inherited methods NOT - // on the subclass's own prototype will NOT be wrapped. - // This test verifies the current behavior matches applyToClass.ts. - @EffectOnClass({ onReturn }) - class SubService extends ParentService { - childMethod() { - return 'child'; - } - } - - const service = new SubService(); - service.childMethod(); - service.parentMethod(); - - // childMethod is on SubService.prototype (own property) -> wrapped - // parentMethod is on ParentService.prototype (inherited, not own) -> NOT wrapped - // This matches the existing applyToClass behavior using getOwnPropertyNames - expect(onReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('all lifecycle hooks work via EffectOnClass', () => { - it('should fire all hooks in correct order for wrapped methods', () => { - const callOrder: string[] = []; - - const hooks: EffectHooks = { - onInvoke: () => callOrder.push('onInvoke'), - onReturn: ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: () => callOrder.push('finally'), - }; - - @EffectOnClass(hooks) - class TestService { - greet(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = service.greet('world'); - - expect(result).toBe('hello world'); - expect(callOrder).toEqual(['onInvoke', 'original', 'onReturn', 'finally']); - }); - }); - - describe('async methods work via EffectOnClass', () => { - it('should handle async methods correctly', async () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn }) - class TestService { - async fetchData(id: number) { - return { id, name: 'test' }; - } - } - - const service = new TestService(); - const result = await service.fetchData(1); - - expect(result).toEqual({ id: 1, name: 'test' }); - expect(onReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('EFFECT_APPLIED_KEY is set on methods wrapped by EffectOnClass', () => { - it('should mark wrapped methods with EFFECT_APPLIED_KEY by default', () => { - @EffectOnClass({}) - class TestService { - doWork() { - return 'work'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'doWork', - )!; - - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBe(true); - }); - - it('should mark methods with custom exclusionKey instead of EFFECT_APPLIED_KEY', () => { - const CUSTOM_KEY = Symbol('customApplied'); - - @EffectOnClass({}, CUSTOM_KEY) - class TestService { - doWork() { - return 'work'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'doWork', - )!; - - // Custom key should be set - expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); - // Default EFFECT_APPLIED_KEY should NOT be set - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBeUndefined(); - }); - }); - - describe('independent decorators with different exclusionKeys do not interfere', () => { - it('should allow two class-level decorators to both wrap methods', () => { - const LOG_KEY = Symbol('logApplied'); - const METRICS_KEY = Symbol('metricsApplied'); - - const logOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const metricsOnReturn = vi.fn(({ result }: { result: unknown }) => result); - - // Apply two independent class-level decorators - @EffectOnClass({ onReturn: metricsOnReturn }, METRICS_KEY) - @EffectOnClass({ onReturn: logOnReturn }, LOG_KEY) - class TestService { - doWork() { - return 'work'; - } - } - - const service = new TestService(); - service.doWork(); - - // Both decorators should have fired because they use different keys - expect(logOnReturn).toHaveBeenCalledOnce(); - expect(metricsOnReturn).toHaveBeenCalledOnce(); - }); - - it('should skip methods decorated by same exclusionKey but not by different key', () => { - const LOG_KEY = Symbol('logApplied'); - const METRICS_KEY = Symbol('metricsApplied'); - - const classLogOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const methodLogOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const metricsOnReturn = vi.fn(({ result }: { result: unknown }) => result); - - @EffectOnClass({ onReturn: metricsOnReturn }, METRICS_KEY) - @EffectOnClass({ onReturn: classLogOnReturn }, LOG_KEY) - class TestService { - @EffectOnMethod({ onReturn: methodLogOnReturn }, LOG_KEY) - decoratedMethod() { - return 'decorated'; - } - - plainMethod() { - return 'plain'; - } - } - - const service = new TestService(); - service.decoratedMethod(); - service.plainMethod(); - - // Method-level Log fires for decoratedMethod - expect(methodLogOnReturn).toHaveBeenCalledOnce(); - // Class-level Log skips decoratedMethod (same LOG_KEY), fires for plainMethod - expect(classLogOnReturn).toHaveBeenCalledOnce(); - // Metrics fires for both (uses METRICS_KEY, not LOG_KEY) - expect(metricsOnReturn).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/tests/EffectOnMethod.spec.ts b/tests/EffectOnMethod.spec.ts deleted file mode 100644 index d9ecb74..0000000 --- a/tests/EffectOnMethod.spec.ts +++ /dev/null @@ -1,1013 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { - EffectOnMethod, - EFFECT_APPLIED_KEY, -} from '../src/effect-on-method'; -import { setMeta, getMeta, SetMeta } from '../src/set-meta.decorator'; -import type { EffectHooks, OnReturnContext, OnErrorContext } from '../src/hook.types'; - -describe('EffectOnMethod', () => { - describe('sync method with all 4 hooks fires in correct order', () => { - it('should fire hooks in order: onInvoke, original, onReturn, finally', () => { - const callOrder: string[] = []; - - const hooks: EffectHooks = { - onInvoke: () => callOrder.push('onInvoke'), - onReturn: ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: () => callOrder.push('finally'), - }; - - class TestService { - @EffectOnMethod(hooks) - greet(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = service.greet('world'); - - expect(result).toBe('hello world'); - expect(callOrder).toEqual(['onInvoke', 'original', 'onReturn', 'finally']); - }); - }); - - describe('sync method error path fires hooks in correct order', () => { - it('should fire hooks in order: onInvoke, original, onError, finally', () => { - const callOrder: string[] = []; - const testError = new Error('sync failure'); - - const hooks: EffectHooks = { - onInvoke: () => callOrder.push('onInvoke'), - onReturn: ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: () => callOrder.push('finally'), - }; - - class TestService { - @EffectOnMethod(hooks) - failingMethod() { - callOrder.push('original'); - throw testError; - } - } - - const service = new TestService(); - expect(() => service.failingMethod()).toThrow(testError); - expect(callOrder).toEqual(['onInvoke', 'original', 'onError', 'finally']); - }); - }); - - describe('async method with all 4 hooks fires in correct order', () => { - it('should fire hooks in order: onInvoke, original, onReturn, finally', async () => { - const callOrder: string[] = []; - - const hooks: EffectHooks = { - onInvoke: () => callOrder.push('onInvoke'), - onReturn: ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: () => callOrder.push('finally'), - }; - - class TestService { - @EffectOnMethod(hooks) - async greetAsync(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = await service.greetAsync('world'); - - expect(result).toBe('hello world'); - expect(callOrder).toEqual(['onInvoke', 'original', 'onReturn', 'finally']); - }); - }); - - describe('async method error path fires hooks in correct order', () => { - it('should fire hooks in order: onInvoke, original, onError, finally', async () => { - const callOrder: string[] = []; - const testError = new Error('async failure'); - - const hooks: EffectHooks = { - onInvoke: () => callOrder.push('onInvoke'), - onReturn: ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: () => callOrder.push('finally'), - }; - - class TestService { - @EffectOnMethod(hooks) - async failingAsync() { - callOrder.push('original'); - throw testError; - } - } - - const service = new TestService(); - await expect(service.failingAsync()).rejects.toThrow(testError); - expect(callOrder).toEqual(['onInvoke', 'original', 'onError', 'finally']); - }); - }); - - describe('only onInvoke hook provided (others omitted)', () => { - it('should fire onInvoke and execute method normally', () => { - const onInvoke = vi.fn(); - - class TestService { - @EffectOnMethod({ onInvoke }) - compute(a: number, b: number) { - return a + b; - } - } - - const service = new TestService(); - const result = service.compute(3, 5); - - expect(result).toBe(8); - expect(onInvoke).toHaveBeenCalledOnce(); - }); - }); - - describe('only onReturn hook provided', () => { - it('should fire onReturn after method completes', () => { - const onReturn = vi.fn(({ result }: { result: number }) => result * 2); - - class TestService { - @EffectOnMethod({ onReturn }) - compute(a: number, b: number) { - return a + b; - } - } - - const service = new TestService(); - const result = service.compute(3, 5); - - expect(result).toBe(16); - expect(onReturn).toHaveBeenCalledOnce(); - }); - }); - - describe('onError hook receives the thrown error', () => { - it('should pass the error to onError hook', () => { - const testError = new Error('specific error'); - const onError = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); - - class TestService { - @EffectOnMethod({ onError }) - failing() { - throw testError; - } - } - - const service = new TestService(); - expect(() => service.failing()).toThrow(testError); - expect(onError).toHaveBeenCalledOnce(); - expect(onError.mock.calls[0][0].error).toBe(testError); - }); - }); - - describe('finally hook fires on both success and error paths', () => { - it('should fire finally on success', () => { - const finallyHook = vi.fn(); - - class TestService { - @EffectOnMethod({ finally: finallyHook }) - succeed() { - return 'ok'; - } - } - - const service = new TestService(); - service.succeed(); - expect(finallyHook).toHaveBeenCalledOnce(); - }); - - it('should fire finally on error', () => { - const finallyHook = vi.fn(); - - class TestService { - @EffectOnMethod({ finally: finallyHook }) - fail() { - throw new Error('fail'); - } - } - - const service = new TestService(); - expect(() => service.fail()).toThrow(); - expect(finallyHook).toHaveBeenCalledOnce(); - }); - - it('should fire finally on async success', async () => { - const finallyHook = vi.fn(); - - class TestService { - @EffectOnMethod({ finally: finallyHook }) - async succeedAsync() { - return 'ok'; - } - } - - const service = new TestService(); - await service.succeedAsync(); - expect(finallyHook).toHaveBeenCalledOnce(); - }); - - it('should fire finally on async error', async () => { - const finallyHook = vi.fn(); - - class TestService { - @EffectOnMethod({ finally: finallyHook }) - async failAsync() { - throw new Error('fail'); - } - } - - const service = new TestService(); - await expect(service.failAsync()).rejects.toThrow(); - expect(finallyHook).toHaveBeenCalledOnce(); - }); - }); - - describe('onReturn return value replaces method result', () => { - it('should replace sync method result with onReturn value', () => { - class TestService { - @EffectOnMethod({ - onReturn: () => 'replaced', - }) - original() { - return 'original'; - } - } - - const service = new TestService(); - expect(service.original()).toBe('replaced'); - }); - - it('should replace async method result with onReturn value', async () => { - class TestService { - @EffectOnMethod({ - onReturn: () => 'replaced', - }) - async originalAsync() { - return 'original'; - } - } - - const service = new TestService(); - expect(await service.originalAsync()).toBe('replaced'); - }); - }); - - describe('this context is correct inside wrapped method', () => { - it('should preserve this context for sync methods', () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - class TestService { - value = 42; - - @EffectOnMethod({ onReturn }) - getValue() { - return this.value; - } - } - - const service = new TestService(); - expect(service.getValue()).toBe(42); - }); - - it('should preserve this context for async methods', async () => { - class TestService { - value = 'async-value'; - - @EffectOnMethod({ - onReturn: ({ result }) => result, - }) - async getValueAsync() { - return this.value; - } - } - - const service = new TestService(); - expect(await service.getValueAsync()).toBe('async-value'); - }); - }); - - describe('EFFECT_APPLIED_KEY is set on wrapped function', () => { - it('should be retrievable via getMeta on the descriptor', () => { - class TestService { - @EffectOnMethod({}) - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBe(true); - }); - }); - - describe('metadata from original function is preserved on wrapped function', () => { - it('should copy _symMeta from original to wrapped function', () => { - const customKey = Symbol('customMeta'); - - class TestService { - @EffectOnMethod({}) - @SetMeta(customKey, 'preserved-value') - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - expect(getMeta(customKey, descriptor)).toBe('preserved-value'); - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBe(true); - }); - - it('should preserve metadata set via setMeta before wrapping', () => { - const markerKey = Symbol('marker'); - - class TestService { - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - setMeta(markerKey, 'before-wrap', descriptor); - - const decorator = EffectOnMethod({}); - decorator(TestService.prototype, 'myMethod', descriptor); - - expect(getMeta(markerKey, descriptor)).toBe('before-wrap'); - }); - }); - - describe('hook that throws propagates error to caller', () => { - it('should propagate onReturn hook error', () => { - const hookError = new Error('hook failure'); - - class TestService { - @EffectOnMethod({ - onReturn: () => { - throw hookError; - }, - }) - succeed() { - return 'ok'; - } - } - - const service = new TestService(); - expect(() => service.succeed()).toThrow(hookError); - }); - - it('should propagate async onReturn hook error', async () => { - const hookError = new Error('async hook failure'); - - class TestService { - @EffectOnMethod({ - onReturn: () => { - throw hookError; - }, - }) - async succeedAsync() { - return 'ok'; - } - } - - const service = new TestService(); - await expect(service.succeedAsync()).rejects.toThrow(hookError); - }); - - it('should propagate onError hook error to caller', () => { - const onErrorHookError = new Error('onError hook error'); - - class TestService { - @EffectOnMethod({ - onError: () => { - throw onErrorHookError; - }, - }) - failing() { - throw new Error('original'); - } - } - - const service = new TestService(); - expect(() => service.failing()).toThrow(onErrorHookError); - }); - }); - - describe('method with no hooks applied (empty options) still executes normally', () => { - it('should execute sync method normally with empty hooks', () => { - class TestService { - @EffectOnMethod({}) - add(a: number, b: number) { - return a + b; - } - } - - const service = new TestService(); - expect(service.add(2, 3)).toBe(5); - }); - - it('should execute async method normally with empty hooks', async () => { - class TestService { - @EffectOnMethod({}) - async addAsync(a: number, b: number) { - return a + b; - } - } - - const service = new TestService(); - expect(await service.addAsync(2, 3)).toBe(5); - }); - }); - - describe('hook argument signatures', () => { - it('should pass correct arguments to onInvoke', () => { - const onInvoke = vi.fn(); - - class TestService { - @EffectOnMethod({ onInvoke }) - greet(name: string, age: number) { - return `${name} ${age}`; - } - } - - const service = new TestService(); - service.greet('Alice', 30); - - expect(onInvoke).toHaveBeenCalledOnce(); - const [context] = onInvoke.mock.calls[0]; - expect(context.argsObject).toEqual({ name: 'Alice', age: 30 }); - expect(context.target).toBe(service); - expect(context.propertyKey).toBe('greet'); - expect(context.descriptor).toBeDefined(); - expect(typeof context.descriptor.value).toBe('function'); - }); - - it('should pass correct arguments to onReturn', () => { - const onReturn = vi.fn((ctx: OnReturnContext) => ctx.result); - - class TestService { - @EffectOnMethod({ onReturn }) - greet(name: string) { - return `hello ${name}`; - } - } - - const service = new TestService(); - service.greet('Bob'); - - expect(onReturn).toHaveBeenCalledOnce(); - const [context] = onReturn.mock.calls[0]; - expect(context.argsObject).toEqual({ name: 'Bob' }); - expect(context.target).toBe(service); - expect(context.propertyKey).toBe('greet'); - expect(context.result).toBe('hello Bob'); - expect(context.descriptor).toBeDefined(); - }); - - it('should pass correct arguments to onError', () => { - const testError = new Error('test'); - const onError = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); - - class TestService { - @EffectOnMethod({ onError }) - failing(input: string) { - throw testError; - } - } - - const service = new TestService(); - expect(() => service.failing('data')).toThrow(testError); - - expect(onError).toHaveBeenCalledOnce(); - const [context] = onError.mock.calls[0]; - expect(context.argsObject).toEqual({ input: 'data' }); - expect(context.target).toBe(service); - expect(context.propertyKey).toBe('failing'); - expect(context.error).toBe(testError); - expect(context.descriptor).toBeDefined(); - }); - - it('should pass correct arguments to finally hook', () => { - const finallyHook = vi.fn(); - - class TestService { - @EffectOnMethod({ finally: finallyHook }) - greet(name: string) { - return `hello ${name}`; - } - } - - const service = new TestService(); - service.greet('Charlie'); - - expect(finallyHook).toHaveBeenCalledOnce(); - const [context] = finallyHook.mock.calls[0]; - expect(context.argsObject).toEqual({ name: 'Charlie' }); - expect(context.target).toBe(service); - expect(context.propertyKey).toBe('greet'); - expect(context.descriptor).toBeDefined(); - }); - }); - - describe('if no onError hook, the original error is re-thrown', () => { - it('should re-throw original error when no onError hook is provided', () => { - const testError = new Error('original error'); - - class TestService { - @EffectOnMethod({}) - failing() { - throw testError; - } - } - - const service = new TestService(); - expect(() => service.failing()).toThrow(testError); - }); - - it('should re-throw original error for async method when no onError hook', async () => { - const testError = new Error('async original error'); - - class TestService { - @EffectOnMethod({}) - async failingAsync() { - throw testError; - } - } - - const service = new TestService(); - await expect(service.failingAsync()).rejects.toThrow(testError); - }); - }); - - describe('onError hook can return recovery value', () => { - it('should return recovery value from onError for sync method', () => { - class TestService { - @EffectOnMethod({ - onError: () => 'recovered' as unknown, - }) - failing(): string { - throw new Error('fail'); - } - } - - const service = new TestService(); - expect(service.failing()).toBe('recovered'); - }); - - it('should return recovery value from onError for async method', async () => { - class TestService { - @EffectOnMethod({ - onError: () => 'recovered' as unknown, - }) - async failingAsync(): Promise { - throw new Error('fail'); - } - } - - const service = new TestService(); - expect(await service.failingAsync()).toBe('recovered'); - }); - }); - - describe('async hook optimization: .then/.catch only attached when hooks defined', () => { - it('should not attach .then when onReturn is undefined (async success path)', async () => { - const onInvoke = vi.fn(); - - class TestService { - @EffectOnMethod({ onInvoke }) - async fetchData(id: number) { - return { id, data: 'result' }; - } - } - - const service = new TestService(); - const result = await service.fetchData(42); - - expect(result).toEqual({ id: 42, data: 'result' }); - expect(onInvoke).toHaveBeenCalledOnce(); - }); - - it('should not attach .catch when onError is undefined (async error path)', async () => { - const testError = new Error('unhandled async error'); - - class TestService { - @EffectOnMethod({}) - async failingAsync() { - throw testError; - } - } - - const service = new TestService(); - await expect(service.failingAsync()).rejects.toThrow(testError); - }); - - it('should attach only .then when only onReturn is defined (no onError)', async () => { - const onReturn = vi.fn(({ result }: { result: unknown }) => result); - - class TestService { - @EffectOnMethod({ onReturn }) - async fetchData(id: number) { - return { id }; - } - } - - const service = new TestService(); - const result = await service.fetchData(1); - - expect(result).toEqual({ id: 1 }); - expect(onReturn).toHaveBeenCalledOnce(); - }); - - it('should attach only .catch when only onError is defined (no onReturn)', async () => { - const testError = new Error('async error'); - const onError = vi.fn((ctx: OnErrorContext) => { throw ctx.error; }); - - class TestService { - @EffectOnMethod({ onError }) - async failingAsync() { - throw testError; - } - } - - const service = new TestService(); - await expect(service.failingAsync()).rejects.toThrow(testError); - expect(onError).toHaveBeenCalledOnce(); - }); - - it('should work with empty hooks on async method (no .then/.catch/.finally)', async () => { - class TestService { - @EffectOnMethod({}) - async computeAsync(a: number, b: number) { - return a + b; - } - } - - const service = new TestService(); - expect(await service.computeAsync(10, 20)).toBe(30); - }); - }); - - describe('async hooks support', () => { - it('should wait for async onInvoke before executing async method', async () => { - const callOrder: string[] = []; - - const hooks: EffectHooks> = { - onInvoke: async () => { - callOrder.push('onInvoke-start'); - await new Promise((resolve) => setTimeout(resolve, 10)); - callOrder.push('onInvoke-end'); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async greet(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = await service.greet('world'); - - expect(result).toBe('hello world'); - expect(callOrder).toEqual(['onInvoke-start', 'onInvoke-end', 'original']); - }); - - it('should wait for async onReturn and replace async method result', async () => { - const hooks: EffectHooks> = { - onReturn: async ({ result }) => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return `${result}-async`; - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async greet(name: string) { - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = await service.greet('world'); - - expect(result).toBe('hello world-async'); - }); - - it('should allow async onError to return recovery value', async () => { - const hooks: EffectHooks> = { - onError: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return 'recovered-async'; - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async failing() { - throw new Error('fail'); - } - } - - const service = new TestService(); - const result = await service.failing(); - - expect(result).toBe('recovered-async'); - }); - - it('should wait for async finally on success', async () => { - const callOrder: string[] = []; - - const hooks: EffectHooks> = { - finally: async () => { - callOrder.push('finally-start'); - await new Promise((resolve) => setTimeout(resolve, 10)); - callOrder.push('finally-end'); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async greet(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - const result = await service.greet('world'); - - expect(result).toBe('hello world'); - expect(callOrder).toEqual(['original', 'finally-start', 'finally-end']); - }); - - it('should wait for async finally on error', async () => { - const callOrder: string[] = []; - - const hooks: EffectHooks> = { - finally: async () => { - callOrder.push('finally-start'); - await new Promise((resolve) => setTimeout(resolve, 10)); - callOrder.push('finally-end'); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async failing() { - callOrder.push('original'); - throw new Error('fail'); - } - } - - const service = new TestService(); - await expect(service.failing()).rejects.toThrow('fail'); - expect(callOrder).toEqual(['original', 'finally-start', 'finally-end']); - }); - - it('should trigger finally when async onError re-throws on rejection path', async () => { - const callOrder: string[] = []; - const testError = new Error('original error'); - - const hooks: EffectHooks> = { - onError: async ({ error }) => { - callOrder.push('onError-start'); - await new Promise((resolve) => setTimeout(resolve, 10)); - callOrder.push('onError-end'); - throw error; - }, - finally: () => { - callOrder.push('finally'); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async failing() { - callOrder.push('original'); - throw testError; - } - } - - const service = new TestService(); - await expect(service.failing()).rejects.toThrow(testError); - expect(callOrder).toEqual(['original', 'onError-start', 'onError-end', 'finally']); - }); - - it('should execute all async hooks in correct order', async () => { - const callOrder: string[] = []; - - const hooks: EffectHooks> = { - onInvoke: async () => { - callOrder.push('onInvoke'); - }, - onReturn: async ({ result }) => { - callOrder.push('onReturn'); - return result; - }, - onError: async ({ error }) => { - callOrder.push('onError'); - throw error; - }, - finally: async () => { - callOrder.push('finally'); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async greet(name: string) { - callOrder.push('original'); - return `hello ${name}`; - } - } - - const service = new TestService(); - await service.greet('world'); - - expect(callOrder).toEqual(['onInvoke', 'original', 'onReturn', 'finally']); - }); - - it('should propagate async onInvoke rejection without calling method', async () => { - const invokeError = new Error('async onInvoke failed'); - const callOrder: string[] = []; - - const hooks: EffectHooks> = { - onInvoke: async () => { - callOrder.push('onInvoke'); - throw invokeError; - }, - }; - - class TestService { - @EffectOnMethod(hooks) - async greet() { - callOrder.push('original'); - return 'hello'; - } - } - - const service = new TestService(); - await expect(service.greet()).rejects.toThrow(invokeError); - expect(callOrder).toEqual(['onInvoke']); - }); - - it('should turn sync method into Promise when onInvoke is async', async () => { - const hooks: EffectHooks = { - onInvoke: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }, - }; - - class TestService { - @EffectOnMethod(hooks) - greet() { - return 'hello'; - } - } - - const service = new TestService(); - const result = service.greet(); - - expect(result).toBeInstanceOf(Promise); - expect(await result).toBe('hello'); - }); - - }); - - describe('exclusionKey parameter', () => { - it('should mark method with EFFECT_APPLIED_KEY by default', () => { - class TestService { - @EffectOnMethod({}) - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBe(true); - }); - - it('should mark method with custom exclusionKey when provided', () => { - const CUSTOM_KEY = Symbol('customApplied'); - - class TestService { - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - EffectOnMethod({}, CUSTOM_KEY)(TestService.prototype, 'myMethod', descriptor); - - // Custom key should be set - expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); - // Default EFFECT_APPLIED_KEY should NOT be set - expect(getMeta(EFFECT_APPLIED_KEY, descriptor)).toBeUndefined(); - }); - - it('should allow independent decorators with different exclusionKeys', () => { - const LOG_KEY = Symbol('logApplied'); - const METRICS_KEY = Symbol('metricsApplied'); - - const logOnReturn = vi.fn(({ result }: { result: unknown }) => result); - const metricsOnReturn = vi.fn(({ result }: { result: unknown }) => result); - - class TestService { - myMethod() { - return 'result'; - } - } - - const descriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - - // Apply "Log" decorator with LOG_KEY - EffectOnMethod({ onReturn: logOnReturn }, LOG_KEY)( - TestService.prototype, - 'myMethod', - descriptor, - ); - // Apply "Metrics" decorator with METRICS_KEY - EffectOnMethod({ onReturn: metricsOnReturn }, METRICS_KEY)( - TestService.prototype, - 'myMethod', - descriptor, - ); - - Object.defineProperty(TestService.prototype, 'myMethod', descriptor); - - const service = new TestService(); - service.myMethod(); - - // Both decorators should have fired (no interference) - expect(logOnReturn).toHaveBeenCalledOnce(); - expect(metricsOnReturn).toHaveBeenCalledOnce(); - - // Both keys should be set on the method - const finalDescriptor = Object.getOwnPropertyDescriptor( - TestService.prototype, - 'myMethod', - )!; - expect(getMeta(LOG_KEY, finalDescriptor)).toBe(true); - expect(getMeta(METRICS_KEY, finalDescriptor)).toBe(true); - }); - }); -}); diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts new file mode 100644 index 0000000..b5cca87 --- /dev/null +++ b/tests/Wrap.spec.ts @@ -0,0 +1,571 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { Wrap } from '../src/wrap.decorator'; +import { SetMeta, getMeta } from '../src/set-meta.decorator'; +import { WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import type { WrapFn, WrapContext } from '../src/hook.types'; + +describe('Wrap', () => { + describe('applied to a method', () => { + it('should delegate to WrapOnMethod and wrap the method', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @Wrap(wrapFn) + greet(name: string) { + return `hello ${name}`; + } + } + + const service = new TestService(); + const result = service.greet('world'); + + expect(result).toBe('hello world'); + }); + + it('should set WRAP_APPLIED_KEY on the method descriptor', () => { + const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + + class TestService { + @Wrap(wrapFn) + doWork() { + return 42; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + expect(getMeta(WRAP_APPLIED_KEY, descriptor!)).toBe(true); + }); + }); + + describe('applied to a class', () => { + it('should delegate to WrapOnClass and wrap all prototype methods', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @Wrap(wrapFn) + class TestService { + methodA() { + return 'a'; + } + + methodB() { + return 'b'; + } + } + + const service = new TestService(); + service.methodA(); + service.methodB(); + + expect(calls).toEqual(['methodA', 'methodB']); + }); + + it('should skip methods already decorated with Wrap at method level', () => { + const classCalls: string[] = []; + const methodCalls: string[] = []; + + const classWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + classCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + const methodWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + methodCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @Wrap(classWrapFn) + class TestService { + plain() { + return 'plain'; + } + + @Wrap(methodWrapFn) + decorated() { + return 'decorated'; + } + } + + const service = new TestService(); + service.plain(); + service.decorated(); + + expect(classCalls).toEqual(['plain']); + expect(methodCalls).toEqual(['decorated']); + }); + + it('should return the constructor when applied to a class', () => { + const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + + @Wrap(wrapFn) + class TestService { + doWork() { + return 42; + } + } + + expect(typeof TestService).toBe('function'); + const service = new TestService(); + expect(service.doWork()).toBe(42); + }); + + it('should not wrap getters or setters', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @Wrap(wrapFn) + class TestService { + private _value = 10; + + get value() { + return this._value; + } + + set value(v: number) { + this._value = v; + } + + compute() { + return this._value * 2; + } + } + + const service = new TestService(); + // Access getter -- should NOT trigger the wrapFn + void service.value; + // Use setter -- should NOT trigger the wrapFn + service.value = 20; + // Call regular method -- should trigger the wrapFn + service.compute(); + + expect(calls).toEqual(['compute']); + }); + + it('should not wrap the constructor', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args: unknown[]) => method(...args); + }); + + @Wrap(wrapFnSpy) + class TestService { + value: number; + + constructor() { + this.value = 42; + } + + doWork() { + return this.value; + } + } + + // wrapFn should not be called during construction + expect(wrapFnSpy).not.toHaveBeenCalled(); + + const service = new TestService(); + expect(service.value).toBe(42); + + service.doWork(); + expect(wrapFnSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('WrapFn receives bound method and WrapContext', () => { + it('should pass WrapContext with all expected fields', () => { + let receivedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + receivedContext = context; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @Wrap(wrapFn) + doWork(input: string) { + return input.toUpperCase(); + } + } + + const service = new TestService(); + service.doWork('test'); + + expect(receivedContext).toBeDefined(); + expect(receivedContext!.propertyKey).toBe('doWork'); + expect(receivedContext!.className).toBe('TestService'); + expect(receivedContext!.target).toBe(service); + expect(receivedContext!.parameterNames).toEqual(['input']); + expect(receivedContext!.descriptor).toBeDefined(); + }); + + it('should NOT include args or argsObject on WrapContext', () => { + let receivedContext: Record | undefined; + + const wrapFn: WrapFn = (method, context) => { + receivedContext = context as unknown as Record; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @Wrap(wrapFn) + doWork(a: number, b: number) { + return a + b; + } + } + + const service = new TestService(); + service.doWork(1, 2); + + expect(receivedContext).toBeDefined(); + expect(receivedContext!['args']).toBeUndefined(); + expect(receivedContext!['argsObject']).toBeUndefined(); + }); + + it('should pass a this-bound method to WrapFn', () => { + let receivedMethod: ((...args: unknown[]) => unknown) | undefined; + + const wrapFn: WrapFn = (method, _context) => { + receivedMethod = method; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + name = 'TestInstance'; + + @Wrap(wrapFn) + getName() { + return this.name; + } + } + + const service = new TestService(); + const result = service.getName(); + + // The method returned the correct result via this binding + expect(result).toBe('TestInstance'); + + // Calling the captured bound method directly also works + // (proving it is pre-bound, not requiring a this context) + expect(receivedMethod!()).toBe('TestInstance'); + }); + + it('should bind method to the correct instance for each invocation', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + constructor(private id: number) {} + + @Wrap(wrapFn) + getId() { + return this.id; + } + } + + const serviceA = new TestService(1); + const serviceB = new TestService(2); + + expect(serviceA.getId()).toBe(1); + expect(serviceB.getId()).toBe(2); + }); + }); + + describe('sync method through Wrap', () => { + it('should wrap a sync method and return its result unchanged', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class Calculator { + @Wrap(wrapFn) + add(a: number, b: number) { + return a + b; + } + } + + const calc = new Calculator(); + expect(calc.add(2, 3)).toBe(5); + }); + + it('should allow Wrap to modify the sync return value', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => { + const result = method(...args) as number; + return result * 10; + }; + }; + + class Calculator { + @Wrap(wrapFn) + add(a: number, b: number) { + return a + b; + } + } + + const calc = new Calculator(); + expect(calc.add(2, 3)).toBe(50); + }); + + it('should allow Wrap to intercept arguments for sync methods', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => { + // Intercept: double all numeric arguments + const doubled = args.map((a) => + typeof a === 'number' ? a * 2 : a, + ); + return method(...doubled); + }; + }; + + class Calculator { + @Wrap(wrapFn) + add(a: number, b: number) { + return a + b; + } + } + + const calc = new Calculator(); + // Wrap doubles the arguments: add(4, 6) => 10 + expect(calc.add(2, 3)).toBe(10); + }); + }); + + describe('async method through Wrap', () => { + it('should wrap an async method and return its resolved value', async () => { + const wrapFn: WrapFn = (method, _context) => { + return async (...args: unknown[]) => { + const result = await method(...args); + return result; + }; + }; + + class TestService { + @Wrap(wrapFn) + async fetchData(id: number) { + return { id, name: 'test' }; + } + } + + const service = new TestService(); + const result = await service.fetchData(42); + + expect(result).toEqual({ id: 42, name: 'test' }); + }); + + it('should allow Wrap to modify the async return value', async () => { + const wrapFn: WrapFn = (method, _context) => { + return async (...args: unknown[]) => { + const result = (await method(...args)) as { id: number; name: string }; + return { ...result, modified: true }; + }; + }; + + class TestService { + @Wrap(wrapFn) + async fetchData(id: number) { + return { id, name: 'test' }; + } + } + + const service = new TestService(); + const result = await service.fetchData(42); + + expect(result).toEqual({ id: 42, name: 'test', modified: true }); + }); + + it('should propagate errors from async methods', async () => { + const wrapFn: WrapFn = (method, _context) => { + return async (...args: unknown[]) => { + return method(...args); + }; + }; + + const testError = new Error('async failure'); + + class TestService { + @Wrap(wrapFn) + async failing() { + throw testError; + } + } + + const service = new TestService(); + await expect(service.failing()).rejects.toThrow(testError); + }); + }); + + describe('exclusion key prevents double-wrapping at class level', () => { + it('should use custom exclusionKey for independent decorator isolation', () => { + const CUSTOM_KEY = Symbol('custom'); + + const calls: string[] = []; + const wrapFnA: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(`A:${String(context.propertyKey)}`); + return method(...args); + }; + }; + + const wrapFnB: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(`B:${String(context.propertyKey)}`); + return method(...args); + }; + }; + + @Wrap(wrapFnA) + class TestService { + @Wrap(wrapFnB, CUSTOM_KEY) + doWork() { + return 42; + } + } + + const service = new TestService(); + service.doWork(); + + // Both should be applied since they use different exclusion keys + expect(calls).toContain('A:doWork'); + expect(calls).toContain('B:doWork'); + }); + + it('should prevent double-wrap when class and method use same exclusionKey', () => { + const SAME_KEY = Symbol('sameKey'); + const classCalls: string[] = []; + const methodCalls: string[] = []; + + const classWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + classCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + const methodWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + methodCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @Wrap(classWrapFn, SAME_KEY) + class TestService { + @Wrap(methodWrapFn, SAME_KEY) + decoratedMethod() { + return 'result'; + } + + plainMethod() { + return 'plain'; + } + } + + const service = new TestService(); + service.decoratedMethod(); + service.plainMethod(); + + // Method-level fires for decoratedMethod, class-level is skipped + expect(methodCalls).toEqual(['decoratedMethod']); + // Class-level fires only for plainMethod + expect(classCalls).toEqual(['plainMethod']); + }); + + it('should skip methods marked with SetMeta using the exclusionKey', () => { + const EXCLUSION_KEY = Symbol('noWrap'); + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @Wrap(wrapFn, EXCLUSION_KEY) + class TestService { + @SetMeta(EXCLUSION_KEY, true) + excluded() { + return 'excluded'; + } + + included() { + return 'included'; + } + } + + const service = new TestService(); + service.excluded(); + service.included(); + + // Only included method should be wrapped + expect(calls).toEqual(['included']); + }); + + it('should mark method with exclusionKey when applied at method level', () => { + const EXCLUSION_KEY = Symbol('customKey'); + const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + + class TestService { + @Wrap(wrapFn, EXCLUSION_KEY) + myMethod() { + return 'result'; + } + } + + const service = new TestService(); + expect(service.myMethod()).toBe('result'); + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'myMethod', + )!; + expect(getMeta(EXCLUSION_KEY, descriptor)).toBe(true); + }); + }); + + describe('invalid decorator context', () => { + it('should throw Error when applied in an unsupported context', () => { + const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + + const decorator = Wrap(wrapFn); + + // Simulate invalid context: propertyKey present but no descriptor + expect(() => { + (decorator as Function)({}, 'someMethod', undefined); + }).toThrow('Wrap decorator can only be applied to classes or methods'); + }); + + it('should throw Error with propertyKey present but descriptor missing', () => { + const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + + const decorator = Wrap(wrapFn); + + // PropertyKey is a symbol, descriptor is still undefined + expect(() => { + (decorator as Function)({}, Symbol('test'), undefined); + }).toThrow('Wrap decorator can only be applied to classes or methods'); + }); + }); +}); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts new file mode 100644 index 0000000..7c2ca22 --- /dev/null +++ b/tests/WrapOnClass.spec.ts @@ -0,0 +1,649 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { WrapOnClass } from '../src/wrap-on-class'; +import { WrapOnMethod, WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import { SetMeta, getMeta } from '../src/set-meta.decorator'; +import type { WrapFn, WrapContext } from '../src/hook.types'; + +describe('WrapOnClass', () => { + describe('wraps all regular prototype methods', () => { + it('should wrap every eligible method with the provided WrapFn', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + methodA() { + return 'a'; + } + + methodB() { + return 'b'; + } + + methodC() { + return 'c'; + } + } + + const service = new TestService(); + expect(service.methodA()).toBe('a'); + expect(service.methodB()).toBe('b'); + expect(service.methodC()).toBe('c'); + + expect(calls).toEqual(['methodA', 'methodB', 'methodC']); + }); + + it('should preserve correct return values from wrapped methods', () => { + const wrapFn: WrapFn = (method) => { + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn) + class Calculator { + add(a: number, b: number) { + return a + b; + } + + multiply(a: number, b: number) { + return a * b; + } + } + + const calc = new Calculator(); + expect(calc.add(2, 3)).toBe(5); + expect(calc.multiply(4, 5)).toBe(20); + }); + }); + + describe('skips constructor', () => { + it('should not fire the wrapper during construction', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args: unknown[]) => method(...args); + }); + + @WrapOnClass(wrapFnSpy) + class TestService { + value: number; + + constructor() { + this.value = 42; + } + + doWork() { + return this.value; + } + } + + const service = new TestService(); + expect(service.value).toBe(42); + + // WrapFn should not have been called during construction + expect(wrapFnSpy).not.toHaveBeenCalled(); + + // But should fire when calling a method + service.doWork(); + expect(wrapFnSpy).toHaveBeenCalledOnce(); + }); + + it('should not include constructor in the set of wrapped property names', () => { + const wrappedNames: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + wrappedNames.push(String(context.propertyKey)); + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn) + class TestService { + constructor() { + // intentionally empty + } + + alpha() { + return 'alpha'; + } + + beta() { + return 'beta'; + } + } + + const service = new TestService(); + service.alpha(); + service.beta(); + + expect(wrappedNames).toEqual(['alpha', 'beta']); + expect(wrappedNames).not.toContain('constructor'); + }); + }); + + describe('skips getters and setters', () => { + it('should not wrap getter or setter accessors', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + private _value = 0; + + get value() { + return this._value; + } + + set value(v: number) { + this._value = v; + } + + doWork() { + return 'work'; + } + } + + const service = new TestService(); + service.value = 10; + void service.value; + service.doWork(); + + // Only doWork should be wrapped, not the getter/setter + expect(calls).toEqual(['doWork']); + }); + + it('should skip getter-only properties', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + get computed() { + return 42; + } + + doWork() { + return 'done'; + } + } + + const service = new TestService(); + void service.computed; + service.doWork(); + + expect(calls).toEqual(['doWork']); + }); + + it('should skip setter-only properties', () => { + const calls: string[] = []; + let stored = 0; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + set data(v: number) { + stored = v; + } + + doWork() { + return 'done'; + } + } + + const service = new TestService(); + service.data = 99; + service.doWork(); + + expect(stored).toBe(99); + expect(calls).toEqual(['doWork']); + }); + }); + + describe('skips non-function prototype values', () => { + it('should not attempt to wrap non-function prototype properties', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + doWork() { + return 'work'; + } + } + + // Add a non-function property to the prototype after decoration + Object.defineProperty(TestService.prototype, 'staticValue', { + value: 42, + writable: true, + enumerable: true, + configurable: true, + }); + + const service = new TestService(); + service.doWork(); + + expect(calls).toEqual(['doWork']); + }); + + it('should skip string and object prototype values', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + class TestService { + doWork() { + return 'work'; + } + } + + // Add non-function values to prototype before applying decorator + Object.defineProperty(TestService.prototype, 'label', { + value: 'test-label', + writable: true, + configurable: true, + }); + Object.defineProperty(TestService.prototype, 'config', { + value: { timeout: 5000 }, + writable: true, + configurable: true, + }); + + WrapOnClass(wrapFn)(TestService); + + const service = new TestService(); + service.doWork(); + + expect(calls).toEqual(['doWork']); + }); + }); + + describe('skips methods marked with exclusion key via SetMeta', () => { + it('should skip methods explicitly excluded via SetMeta with default key', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + @SetMeta(WRAP_APPLIED_KEY, true) + excluded() { + return 'excluded'; + } + + included() { + return 'included'; + } + } + + const service = new TestService(); + service.excluded(); + service.included(); + + // Only included should be wrapped; excluded was marked with SetMeta + expect(calls).toEqual(['included']); + }); + + it('should skip methods explicitly excluded via SetMeta with custom key', () => { + const CUSTOM_KEY = Symbol('custom'); + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn, CUSTOM_KEY) + class TestService { + @SetMeta(CUSTOM_KEY, true) + excluded() { + return 'excluded'; + } + + included() { + return 'included'; + } + } + + const service = new TestService(); + service.excluded(); + service.included(); + + // Only included should be wrapped; excluded was marked with SetMeta + expect(calls).toEqual(['included']); + }); + }); + + describe('skips methods already wrapped at method level', () => { + it('should skip methods already decorated with WrapOnMethod (default key)', () => { + const classCalls: string[] = []; + const methodCalls: string[] = []; + + const classWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + classCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + const methodWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + methodCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(classWrapFn) + class TestService { + @WrapOnMethod(methodWrapFn) + alreadyWrapped() { + return 'wrapped'; + } + + notWrapped() { + return 'not wrapped'; + } + } + + const service = new TestService(); + service.alreadyWrapped(); + service.notWrapped(); + + // alreadyWrapped was decorated by WrapOnMethod, so class decorator skips it + expect(methodCalls).toEqual(['alreadyWrapped']); + expect(classCalls).toEqual(['notWrapped']); + }); + + it('should skip methods already decorated with WrapOnMethod (custom key)', () => { + const CUSTOM_KEY = Symbol('custom'); + const classCalls: string[] = []; + const methodCalls: string[] = []; + + const classWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + classCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + const methodWrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + methodCalls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(classWrapFn, CUSTOM_KEY) + class TestService { + @WrapOnMethod(methodWrapFn, CUSTOM_KEY) + alreadyWrapped() { + return 'wrapped'; + } + + notWrapped() { + return 'not wrapped'; + } + } + + const service = new TestService(); + service.alreadyWrapped(); + service.notWrapped(); + + // The custom exclusion key should still prevent double-wrapping + expect(methodCalls).toEqual(['alreadyWrapped']); + expect(classCalls).toEqual(['notWrapped']); + }); + }); + + describe('WRAP_APPLIED_KEY used as default exclusion key', () => { + it('should default to WRAP_APPLIED_KEY when no exclusionKey is provided', () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + @SetMeta(WRAP_APPLIED_KEY, true) + excluded() { + return 'excluded'; + } + + included() { + return 'included'; + } + } + + const service = new TestService(); + service.excluded(); + service.included(); + + // excluded was marked with WRAP_APPLIED_KEY, so WrapOnClass skips it + expect(calls).toEqual(['included']); + }); + + it('should set WRAP_APPLIED_KEY metadata on methods it wraps', () => { + const wrapFn: WrapFn = (method) => { + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn) + class TestService { + doWork() { + return 'work'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + )!; + expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBe(true); + }); + }); + + describe('custom exclusion key propagated to WrapOnMethod', () => { + it('should set custom exclusion key metadata on wrapped methods', () => { + const CUSTOM_KEY = Symbol('custom'); + + const wrapFn: WrapFn = (method) => { + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn, CUSTOM_KEY) + class TestService { + doWork() { + return 'work'; + } + } + + // WrapOnClass delegates to WrapOnMethod with the custom key, + // so the wrapped method should have the custom key metadata set + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + )!; + expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); + }); + + it('should not set WRAP_APPLIED_KEY when a custom key is provided', () => { + const CUSTOM_KEY = Symbol('custom'); + + const wrapFn: WrapFn = (method) => { + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn, CUSTOM_KEY) + class TestService { + doWork() { + return 'work'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + )!; + + // Custom key should be set + expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); + // Default WRAP_APPLIED_KEY should NOT be set since custom key was used + expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBeUndefined(); + }); + + it('should allow different WrapOnClass decorators with different keys', () => { + const KEY_A = Symbol('keyA'); + const KEY_B = Symbol('keyB'); + const callsA: string[] = []; + const callsB: string[] = []; + + const wrapFnA: WrapFn = (method, context) => { + return (...args: unknown[]) => { + callsA.push(String(context.propertyKey)); + return method(...args); + }; + }; + + const wrapFnB: WrapFn = (method, context) => { + return (...args: unknown[]) => { + callsB.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFnA, KEY_A) + @WrapOnClass(wrapFnB, KEY_B) + class TestService { + doWork() { + return 'work'; + } + } + + const service = new TestService(); + service.doWork(); + + // Both class-level decorators should wrap the method since they use + // different exclusion keys and do not interfere with each other + expect(callsA).toEqual(['doWork']); + expect(callsB).toEqual(['doWork']); + }); + }); + + describe('this binding is preserved', () => { + it('should preserve this context in wrapped methods', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn) + class TestService { + name = 'test'; + + getName() { + return this.name; + } + } + + const service = new TestService(); + expect(service.getName()).toBe('test'); + }); + }); + + describe('WrapContext is correctly populated', () => { + it('should pass correct WrapContext fields for each wrapped method', () => { + const capturedContexts: WrapContext[] = []; + + const wrapFn: WrapFn = (method, context) => { + capturedContexts.push(context); + return (...args: unknown[]) => method(...args); + }; + + @WrapOnClass(wrapFn) + class TestService { + greet(name: string) { + return `hello ${name}`; + } + } + + const service = new TestService(); + service.greet('world'); + + expect(capturedContexts).toHaveLength(1); + + const ctx = capturedContexts[0]; + expect(ctx.target).toBe(service); + expect(ctx.propertyKey).toBe('greet'); + expect(ctx.parameterNames).toEqual(['name']); + expect(ctx.className).toBe('TestService'); + expect(ctx.descriptor).toBeDefined(); + }); + }); + + describe('async methods', () => { + it('should wrap async methods correctly', async () => { + const calls: string[] = []; + + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + calls.push(String(context.propertyKey)); + return method(...args); + }; + }; + + @WrapOnClass(wrapFn) + class TestService { + async fetchData(id: number) { + return { id, name: 'test' }; + } + } + + const service = new TestService(); + const result = await service.fetchData(1); + + expect(result).toEqual({ id: 1, name: 'test' }); + expect(calls).toEqual(['fetchData']); + }); + }); +}); diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts new file mode 100644 index 0000000..f2f4aea --- /dev/null +++ b/tests/WrapOnMethod.spec.ts @@ -0,0 +1,503 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { WrapOnMethod, WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import { getMeta, SetMeta } from '../src/set-meta.decorator'; +import type { WrapFn, WrapContext } from '../src/hook.types'; + +describe('WrapOnMethod', () => { + describe('WRAP_APPLIED_KEY', () => { + it('should be a unique symbol', () => { + expect(typeof WRAP_APPLIED_KEY).toBe('symbol'); + expect(WRAP_APPLIED_KEY.toString()).toContain('wrapApplied'); + }); + }); + + describe('basic wrapping', () => { + it('should call wrapFn per invocation with bound method and WrapContext', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args: unknown[]) => method(...args); + }); + + class TestService { + @WrapOnMethod(wrapFnSpy) + greet(name: string) { + return `hello ${name}`; + } + } + + const service = new TestService(); + + expect(wrapFnSpy).not.toHaveBeenCalled(); + + const result = service.greet('world'); + + expect(result).toBe('hello world'); + expect(wrapFnSpy).toHaveBeenCalledTimes(1); + }); + + it('should call wrapFn on each invocation, not at decoration time', () => { + let callCount = 0; + + const wrapFn: WrapFn = (method, _context) => { + callCount++; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + doWork() { + return 42; + } + } + + expect(callCount).toBe(0); + + const service = new TestService(); + service.doWork(); + expect(callCount).toBe(1); + + service.doWork(); + expect(callCount).toBe(2); + + service.doWork(); + expect(callCount).toBe(3); + }); + + it('should return the result from innerFn', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => { + const result = method(...args) as number; + return result * 2; + }; + }; + + class TestService { + @WrapOnMethod(wrapFn) + compute(x: number) { + return x + 1; + } + } + + const service = new TestService(); + expect(service.compute(5)).toBe(12); // (5+1)*2 + }); + }); + + describe('this binding', () => { + it('should bind original method to the correct this context', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + prefix = 'Hello'; + + @WrapOnMethod(wrapFn) + greet(name: string) { + return `${this.prefix}, ${name}`; + } + } + + const service = new TestService(); + expect(service.greet('world')).toBe('Hello, world'); + }); + + it('should pass a pre-bound method that works without explicit this', () => { + let capturedMethod: ((...args: unknown[]) => unknown) | undefined; + + const wrapFn: WrapFn = (method, _context) => { + capturedMethod = method; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + value = 'instance-data'; + + @WrapOnMethod(wrapFn) + getValue() { + return this.value; + } + } + + const service = new TestService(); + service.getValue(); + + // Call the captured method directly -- without .call or .apply. + // It should still resolve `this` because WrapOnMethod pre-binds it. + expect(capturedMethod).toBeDefined(); + expect(capturedMethod!()).toBe('instance-data'); + }); + + it('should provide className from this.constructor.name', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + class MySpecialService { + @WrapOnMethod(wrapFn) + doWork() { + return 'done'; + } + } + + const service = new MySpecialService(); + service.doWork(); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.className).toBe('MySpecialService'); + }); + }); + + describe('WrapContext fields', () => { + it('should provide all expected context fields', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + } + + const service = new TestService(); + service.greet('world', 'hi'); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.target).toBe(service); + expect(capturedContext!.propertyKey).toBe('greet'); + expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); + expect(capturedContext!.className).toBe('TestService'); + expect(capturedContext!.descriptor).toBeDefined(); + expect(typeof capturedContext!.descriptor.value).toBe('function'); + }); + + it('should NOT include args or argsObject in WrapContext', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + doWork(x: number) { + return x; + } + } + + const service = new TestService(); + service.doWork(42); + + expect(capturedContext).toBeDefined(); + expect('args' in capturedContext!).toBe(false); + expect('argsObject' in capturedContext!).toBe(false); + }); + }); + + describe('parameter names extraction', () => { + it('should extract parameter names at decoration time', () => { + const contexts: WrapContext[] = []; + + const wrapFn: WrapFn = (method, context) => { + contexts.push(context); + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + calculate(price: number, tax: number, discount: number) { + return price + tax - discount; + } + } + + const service = new TestService(); + service.calculate(100, 10, 5); + service.calculate(200, 20, 10); + + // Both calls should have the same parameterNames (extracted once at decoration time) + expect(contexts[0].parameterNames).toEqual(['price', 'tax', 'discount']); + expect(contexts[1].parameterNames).toEqual(['price', 'tax', 'discount']); + expect(contexts[0].parameterNames).toBe(contexts[1].parameterNames); // Same reference + }); + + it('should return empty array for a method with no parameters', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + noParams() { + return 'ok'; + } + } + + const service = new TestService(); + service.noParams(); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.parameterNames).toEqual([]); + }); + }); + + describe('exclusion key', () => { + it('should set WRAP_APPLIED_KEY as default exclusion key', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + doWork() { + return 'done'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + + expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBe(true); + }); + + it('should use custom exclusion key when provided', () => { + const CUSTOM_KEY = Symbol('custom'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn, CUSTOM_KEY) + doWork() { + return 'done'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + + expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); + }); + + it('should NOT set default WRAP_APPLIED_KEY when custom key is provided', () => { + const CUSTOM_KEY = Symbol('custom'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn, CUSTOM_KEY) + doWork() { + return 'done'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + + // Only the custom key should be set, not the default WRAP_APPLIED_KEY + expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); + expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBeUndefined(); + }); + }); + + describe('copySymMeta', () => { + it('should preserve SetMeta metadata after wrapping', () => { + const META_KEY = Symbol('testMeta'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + @SetMeta(META_KEY, 'preserved-value') + doWork() { + return 'done'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + + expect(getMeta(META_KEY, descriptor)).toBe('preserved-value'); + }); + + it('should preserve multiple metadata entries', () => { + const KEY_A = Symbol('a'); + const KEY_B = Symbol('b'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + @SetMeta(KEY_A, 'value-a') + @SetMeta(KEY_B, 'value-b') + doWork() { + return 'done'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'doWork', + ); + + expect(getMeta(KEY_A, descriptor)).toBe('value-a'); + expect(getMeta(KEY_B, descriptor)).toBe('value-b'); + }); + }); + + describe('sync method wrapping', () => { + it('should pass through the return value unchanged when wrapper delegates', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + add(a: number, b: number) { + return a + b; + } + } + + const service = new TestService(); + expect(service.add(3, 4)).toBe(7); + }); + + it('should propagate sync errors from the original method', () => { + const syncError = new Error('sync failure'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + failing() { + throw syncError; + } + } + + const service = new TestService(); + expect(() => service.failing()).toThrow(syncError); + }); + }); + + describe('async methods', () => { + it('should work with async methods', async () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + async fetchData(id: number): Promise { + return `data-${id}`; + } + } + + const service = new TestService(); + const result = await service.fetchData(42); + + expect(result).toBe('data-42'); + }); + + it('should allow async wrapper to modify async results', async () => { + const wrapFn: WrapFn> = (method, _context) => { + return async (...args: unknown[]) => { + const result = (await method(...args)) as string; + return `modified: ${result}`; + }; + }; + + class TestService { + @WrapOnMethod(wrapFn) + async fetchData(id: number): Promise { + return `data-${id}`; + } + } + + const service = new TestService(); + const result = await service.fetchData(42); + + expect(result).toBe('modified: data-42'); + }); + + it('should propagate async errors (rejected promises) from the original method', async () => { + const asyncError = new Error('async failure'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + async failingAsync() { + throw asyncError; + } + } + + const service = new TestService(); + await expect(service.failingAsync()).rejects.toThrow(asyncError); + }); + }); + + describe('method decorator return type', () => { + it('should return a valid MethodDecorator', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + const decorator = WrapOnMethod(wrapFn); + expect(typeof decorator).toBe('function'); + }); + + it('should replace descriptor.value with the wrapped function', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + class TestService { + original() { + return 'original'; + } + } + + const originalFn = TestService.prototype.original; + const descriptor = Object.getOwnPropertyDescriptor( + TestService.prototype, + 'original', + )!; + + WrapOnMethod(wrapFn)(TestService.prototype, 'original', descriptor); + + // descriptor.value should now be a different function (the wrapped one) + expect(descriptor.value).not.toBe(originalFn); + expect(typeof descriptor.value).toBe('function'); + }); + }); +}); diff --git a/tests/effect-on-method-base.spec.ts b/tests/effect-on-method-base.spec.ts deleted file mode 100644 index 1af4a5a..0000000 --- a/tests/effect-on-method-base.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { attachHooks, wrapFunction } from '../src/effect-on-method'; -import type { EffectHooks, HookContext, OnReturnContext } from '../src/hook.types'; - -const makeDescriptor = (fn: (...args: unknown[]) => unknown): PropertyDescriptor => ({ - value: fn, - writable: true, - enumerable: true, - configurable: true, -}); - -const makeContext = (overrides: Partial = {}): HookContext => { - const original = () => undefined; - return { - args: [], - argsObject: undefined, - target: {}, - propertyKey: 'method', - parameterNames: [], - className: 'TestCls', - descriptor: makeDescriptor(original), - ...overrides, - }; -}; - -describe('attachHooks', () => { - it('runs the original method and returns its result when hooks are empty', () => { - const original = vi.fn((...args: unknown[]) => (args[0] as number) + 1); - const thisArg = {}; - const args = [2] as unknown[]; - const context = makeContext({ args, target: thisArg }); - - const run = attachHooks(original, thisArg, args, context, {}); - expect(run()).toBe(3); - expect(original).toHaveBeenCalledWith(2); - }); - - it('applies onReturn on sync success', () => { - const original = () => 'a'; - const context = makeContext(); - const run = attachHooks(original, {}, [], context, { - onReturn: ({ result }) => `${result}b`, - }); - expect(run()).toBe('ab'); - }); - - it('applies onError when the original throws', () => { - const original = () => { - throw new Error('fail'); - }; - const context = makeContext(); - const run = attachHooks(original, {}, [], context, { - onError: () => 'recovered', - }); - expect(run()).toBe('recovered'); - }); - - it('calls finally on sync success', () => { - const finallyHook = vi.fn(); - const original = () => 1; - const context = makeContext(); - const run = attachHooks(original, {}, [], context, { finally: finallyHook }); - expect(run()).toBe(1); - expect(finallyHook).toHaveBeenCalledTimes(1); - expect(finallyHook).toHaveBeenCalledWith(context); - }); - - it('resolves async path through chainAsyncHooks', async () => { - const original = () => Promise.resolve(10); - const context = makeContext(); - const run = attachHooks(original, {}, [], context, { - onReturn: ({ result }: OnReturnContext) => result * 2, - }); - await expect(run()).resolves.toBe(20); - }); -}); - -describe('wrapFunction', () => { - it('binds this and args like a decorated method', () => { - class C { - suffix = '!'; - } - const original = function (this: C, name: string) { - return `${name}${this.suffix}`; - }; - const descriptor = makeDescriptor(original as (...args: unknown[]) => unknown); - const wrapped = wrapFunction( - original as (...args: unknown[]) => unknown, - ['name'], - 'greet', - descriptor, - {}, - ); - - const instance = new C(); - expect(wrapped.call(instance, 'hi')).toBe('hi!'); - }); - - it('builds argsObject from parameter names', () => { - let seen: HookContext | undefined; - const original = () => 0; - const descriptor = makeDescriptor(original); - const wrapped = wrapFunction(original, ['a', 'b'], 'm', descriptor, { - onInvoke: (ctx) => { - seen = ctx; - }, - }); - - wrapped.call({}, 1, 2); - expect(seen?.argsObject).toEqual({ a: 1, b: 2 }); - expect(seen?.parameterNames).toEqual(['a', 'b']); - expect(seen?.propertyKey).toBe('m'); - }); - - it('runs async onInvoke then the original', async () => { - const order: string[] = []; - const original = () => { - order.push('original'); - return 1; - }; - const descriptor = makeDescriptor(original); - const wrapped = wrapFunction(original, [], 'm', descriptor, { - onInvoke: async () => { - order.push('onInvoke'); - }, - }); - - await wrapped.call({}); - expect(order).toEqual(['onInvoke', 'original']); - }); - - it('uses hooks factory with per-call context', () => { - const original = (n: number) => n; - const descriptor = makeDescriptor(original as (...args: unknown[]) => unknown); - const factory = vi.fn((_ctx: HookContext): EffectHooks => ({ - onReturn: ({ result }: OnReturnContext) => result + 1, - })); - const wrapped = wrapFunction( - original as (...args: unknown[]) => unknown, - ['n'], - 'm', - descriptor, - factory, - ); - - expect(wrapped.call({}, 5)).toBe(6); - expect(factory).toHaveBeenCalledTimes(1); - expect(factory.mock.calls[0]?.[0]?.args).toEqual([5]); - }); -}); diff --git a/tests/hook-types.spec.ts b/tests/hook-types.spec.ts new file mode 100644 index 0000000..d8eac01 --- /dev/null +++ b/tests/hook-types.spec.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest'; + +import type { + WrapContext, + WrapFn, + HookContext, + HookArgs, + OnReturnContext, + OnErrorContext, + OnInvokeHookType, + OnReturnHookType, + OnErrorHookType, + FinallyHookType, + EffectHooks, + HooksOrFactory, + UnwrapPromise, + MaybeAsync, +} from '../src/hook.types'; + +describe('hook.types', () => { + describe('WrapContext', () => { + it('contains exactly the 5 required fields', () => { + const ctx: WrapContext = { + target: {}, + propertyKey: 'method', + parameterNames: ['a', 'b'], + className: 'TestClass', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + }; + + expect(ctx.target).toBeDefined(); + expect(ctx.propertyKey).toBe('method'); + expect(ctx.parameterNames).toEqual(['a', 'b']); + expect(ctx.className).toBe('TestClass'); + expect(ctx.descriptor).toBeDefined(); + }); + + it('does not contain args or argsObject', () => { + const ctx: WrapContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'TestClass', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + }; + + expect(ctx).not.toHaveProperty('args'); + expect(ctx).not.toHaveProperty('argsObject'); + }); + }); + + describe('WrapFn', () => { + it('accepts a method and WrapContext, returns a function', () => { + const wrapFn: WrapFn = (method, context) => { + return (...args: unknown[]) => { + return method(...args); + }; + }; + + const fakeMethod = (...args: unknown[]) => args[0]; + const ctx: WrapContext = { + target: {}, + propertyKey: 'test', + parameterNames: [], + className: 'Test', + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + }; + + const wrapped = wrapFn(fakeMethod, ctx); + expect(wrapped(42)).toBe(42); + }); + + it('supports generic return type parameter', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => { + return (method(...args) as number) * 2; + }; + }; + + const fakeMethod = (..._args: unknown[]) => 21; + const ctx: WrapContext = { + target: {}, + propertyKey: 'test', + parameterNames: [], + className: 'Test', + descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, + }; + + const wrapped = wrapFn(fakeMethod, ctx); + expect(wrapped()).toBe(42); + }); + }); + + describe('HookContext extends WrapContext', () => { + it('contains all 7 fields (5 from WrapContext + args + argsObject)', () => { + const hookCtx: HookContext = { + target: {}, + propertyKey: 'method', + parameterNames: ['x'], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [1], + argsObject: { x: 1 }, + }; + + expect(hookCtx.target).toBeDefined(); + expect(hookCtx.propertyKey).toBe('method'); + expect(hookCtx.parameterNames).toEqual(['x']); + expect(hookCtx.className).toBe('Cls'); + expect(hookCtx.descriptor).toBeDefined(); + expect(hookCtx.args).toEqual([1]); + expect(hookCtx.argsObject).toEqual({ x: 1 }); + }); + + it('is assignable to WrapContext (structural subtype)', () => { + const hookCtx: HookContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + }; + + const wrapCtx: WrapContext = hookCtx; + expect(wrapCtx.propertyKey).toBe('method'); + }); + }); + + describe('existing type exports remain unchanged', () => { + it('HookArgs type is available', () => { + const args: HookArgs = { a: 1 }; + expect(args).toBeDefined(); + }); + + it('OnReturnContext extends HookContext with result', () => { + const ctx: OnReturnContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + result: 42, + }; + expect(ctx.result).toBe(42); + }); + + it('OnErrorContext extends HookContext with error', () => { + const ctx: OnErrorContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + error: new Error('test'), + }; + expect(ctx.error).toBeDefined(); + }); + + it('EffectHooks type is available', () => { + const hooks: EffectHooks = {}; + expect(hooks).toBeDefined(); + }); + + it('HooksOrFactory type is available', () => { + const hooks: HooksOrFactory = {}; + expect(hooks).toBeDefined(); + }); + + it('UnwrapPromise type is available', () => { + type Result = UnwrapPromise>; + const value: Result = 'test'; + expect(value).toBe('test'); + }); + + it('MaybeAsync type is available', () => { + type Result = MaybeAsync>; + const value: Result = 'test'; + expect(value).toBe('test'); + }); + + it('hook type aliases are available', () => { + const onInvoke: OnInvokeHookType = (_ctx) => {}; + const onReturn: OnReturnHookType = (_ctx) => _ctx.result; + const onError: OnErrorHookType = (_ctx) => { throw _ctx.error; }; + const finallyHook: FinallyHookType = (_ctx) => {}; + + expect(onInvoke).toBeDefined(); + expect(onReturn).toBeDefined(); + expect(onError).toBeDefined(); + expect(finallyHook).toBeDefined(); + }); + }); +}); From fa6b447c415b59c5f10fbc4f7ac8833e5649987f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 19:18:04 +0200 Subject: [PATCH 04/10] refact:simplify effect function --- src/effect.decorator.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index f7fd3cc..f7ff0df 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -53,8 +53,8 @@ import type { export const Effect = ( hooks: HooksOrFactory, exclusionKey?: symbol, -): ClassDecorator & MethodDecorator => { - const effectWrapFn: WrapFn = ( +): ClassDecorator & MethodDecorator => + Wrap(( boundMethod: (...args: unknown[]) => unknown, wrapContext: WrapContext, ) => { @@ -83,10 +83,7 @@ export const Effect = ( return executeMethod(); }; - }; - - return Wrap(effectWrapFn, exclusionKey); -}; + }, exclusionKey); /** * Builds an object mapping parameter names to their values. From 52ca87c1c7c789aa76eae6c7e67a3380dfabe7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 21:10:31 +0200 Subject: [PATCH 05/10] lint: remove unnecesry import --- src/effect.decorator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index f7ff0df..350f25f 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -5,7 +5,6 @@ import type { HooksOrFactory, UnwrapPromise, WrapContext, - WrapFn, } from './hook.types'; /** From f48600076eec33de2a8f45b85efa8d2ca64af878 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 21:32:30 +0200 Subject: [PATCH 06/10] docs: correct readme --- README.md | 139 ++++++++++++++++++++---------------------------------- 1 file changed, 50 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 1b9352b..1899f4a 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,21 @@ Basic decorator primitives for TypeScript. Writing decorators in TS is hard, thi Zero-dependency TypeScript library that provides low-level primitives for creating decorators. Instead of wrestling with property descriptors and prototype traversal, you use two levels of abstraction: - **`Wrap`** β€” the foundational primitive that gives you full control over method execution via a higher-order function -- **`Effect`** β€” a higher-level abstraction built on `Wrap` that provides lifecycle hooks: - - `onInvoke` β€” fired before the method runs - - `onReturn` β€” fired after the method succeeds - - `onError` β€” fired when the method throws - - `finally` β€” fired after either success or failure +- **`Effect`** β€” a higher-level abstraction that provides combined lifecycle hooks. +- **`OnInvokeHook`** β€” decorator that fires before the method runs +- **`OnReturnHook`** β€” decorator that fires after the method succeeds +- **`OnErrorHook`** β€” decorator that fires when the method throws +- **`FinallyHook`** β€” decorator that fires after either success or failure The library handles method wrapping, `this` preservation, async/sync support, parameter name extraction, and metadata management so you can focus on your decorator logic. ### Key Features - **Zero dependencies** β€” tiny footprint, no external packages required -- **Two-level API** β€” `Wrap` for full control, `Effect` for structured lifecycle hooks -- **Unified decorators** β€” both `Wrap` and `Effect` work on classes and methods +- **Unified decorators** β€” all decorators work on classes and methods - **Full async support** β€” promises are handled automatically - **Pre-built args object** β€” arguments are mapped to parameter names and passed into every hook -- **Metadata utilities** β€” `SetMeta`, `getMeta`, and `setMeta` for symbol-keyed method metadata -- **TypeScript native** β€” written in TypeScript with full type definitions +- **Metadata management** β€” Additional tools for storing and retrieving symbol-keyed method metadata ## Installation @@ -51,7 +49,7 @@ npm install base-decorators ## Quick Start -### Using Wrap (full control) +### Using Wrap `Wrap` is the foundational primitive. You receive the original method and a context, and return a replacement function: @@ -61,9 +59,12 @@ import type { WrapContext } from 'base-decorators'; const Log = () => Wrap((method, context: WrapContext) => { console.log('decorating', context.propertyKey); + return (...args: unknown[]) => { console.log('called with', args); + const result = method(...args); + console.log('returned', result); return result; }; @@ -77,6 +78,7 @@ class Calculator { } const calc = new Calculator(); +// logs: "decorating add" calc.add(2, 3); // logs: "called with [2, 3]" // logs: "returned 5" @@ -84,7 +86,7 @@ calc.add(2, 3); ### Using Effect (lifecycle hooks) -`Effect` is built on top of `Wrap` and provides structured lifecycle hooks for common patterns: +`Effect` provides combined lifecycle hooks for common patterns: ```typescript import { Effect } from 'base-decorators'; @@ -100,21 +102,14 @@ class Calculator { } const calc = new Calculator(); -calc.add(2, 3); // logs arguments and result +calc.add(2, 3); +// logs: "add called with [2, 3]" +// logs: "result: 5" ``` ## How It Works -The library is organized in three layers, from low-level to high-level: - -``` -Wrap (raw method wrapping β€” full control) - └─ Effect (lifecycle hook orchestration β€” structured callbacks) - └─ OnInvokeHook, OnReturnHook, OnErrorHook, FinallyHook - (convenience decorators β€” single-hook shortcuts) -``` - -**`Wrap`** is the foundational primitive. It accepts a factory function that receives the original method (already bound to `this`) and a `WrapContext`, and returns a replacement function. You control the entire execution flow: +**`Wrap`** accepts a factory function that receives the original method (already bound to `this`) and a `WrapContext`, and returns a replacement function. You control the entire execution flow: ```typescript import { Wrap } from 'base-decorators'; @@ -128,7 +123,7 @@ const Log = () => Wrap((method, context: WrapContext) => { }); ``` -**`Effect`** is built on top of `Wrap`. Instead of writing the full wrapping logic yourself, you provide lifecycle hooks and Effect handles the execution flow: +**`Effect`**: Instead of writing the full wrapping logic yourself, you provide lifecycle hooks and Effect handles the execution flow: ```typescript import { Effect } from 'base-decorators'; @@ -139,7 +134,7 @@ const Log = () => Effect({ }); ``` -**Convenience hooks** are single-purpose decorators built on Effect for the most common patterns: +**Convenience hooks** are single-purpose decorators for common patterns: ```typescript import { OnInvokeHook } from 'base-decorators'; @@ -147,71 +142,8 @@ import { OnInvokeHook } from 'base-decorators'; const Log = () => OnInvokeHook(({ args }) => console.log('called with', args)); ``` -Choose the level that fits your use case: `Wrap` when you need full control over execution, `Effect` when lifecycle hooks suit your pattern, or convenience hooks for simple single-hook scenarios. - ## Usage -### Wrap decorator - -Use `Wrap` when you need full control over how a method is executed. The wrapper function receives the original method (bound to the correct `this`) and a `WrapContext` with metadata about the decorated method: - -```typescript -import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; - -const Log = () => Wrap((method, context: WrapContext) => { - console.log('decorating', context.propertyKey); - return (...args: unknown[]) => { - console.log('method called with', args); - const result = method(...args); - console.log('method returned', result); - return result; - }; -}); - -class Calculator { - @Log() - add(a: number, b: number) { - return a + b; - } -} - -const calc = new Calculator(); -calc.add(2, 3); -// logs: "method called with [2, 3]" -// logs: "method returned 5" -``` - -### Async Wrap - -`Wrap` works naturally with async methods. Return an async replacement function to handle promises: - -```typescript -import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; - -const AsyncTimer = () => Wrap((method, context: WrapContext) => { - return async (...args: unknown[]) => { - const start = Date.now(); - const result = await method(...args); - console.log(`${String(context.propertyKey)} took ${Date.now() - start}ms`); - return result; - }; -}); - -class UserService { - @AsyncTimer() - async fetchUser(id: number) { - // async work... - return { id, name: 'Alice' }; - } -} - -const service = new UserService(); -await service.fetchUser(1); -// logs: "fetchUser took 12ms" -``` - ### Validate arguments with `OnInvokeHook` ```typescript @@ -273,6 +205,35 @@ class Service { ### Async hooks +All decorators work naturally with async methods. Return an async replacement function to handle promises: + +```typescript +import { Wrap } from 'base-decorators'; +import type { WrapContext } from 'base-decorators'; + +const AsyncTimer = () => Wrap((method, context: WrapContext) => { + return async (...args: unknown[]) => { + const start = Date.now(); + const result = await method(...args); + + console.log(`${String(context.propertyKey)} took ${Date.now() - start}ms`); + return result; + }; +}); + +class UserService { + @AsyncTimer() + async fetchUser(id: number) { + // async work... + return { id, name: 'Alice' }; + } +} + +const service = new UserService(); +await service.fetchUser(1); +// logs: "fetchUser took 12ms" +``` + When the decorated method returns a `Promise`, all hooks may optionally return a `Promise` as well. `onReturn` receives the **unwrapped** resolved value, and the library automatically chains the returned promise so async hooks execute in the correct order. ```typescript @@ -331,7 +292,7 @@ class Worker { ### Class and Method decorators -`Wrap`, `Effect`, and all hook decorators can be used on both classes and methods out of the box. +All hook decorators can be used on both classes and methods out of the box. ```typescript import { Effect } from 'base-decorators'; @@ -388,7 +349,7 @@ class Service { ```typescript const Log = (message: string) => Effect({ onInvoke: () => console.log(message) -}); +}, Symbol('log')); const Validate = () => Effect({ onInvoke: ({ args }) => { From 09165000ca50879109bf51dfcc3d82b23f4359bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 21:44:10 +0200 Subject: [PATCH 07/10] refact: simplify wrap function --- src/index.ts | 4 +- src/wrap-on-class.ts | 8 +- src/wrap-on-method.ts | 121 ++++++++--- tests/Wrap.spec.ts | 4 +- tests/WrapOnClass.spec.ts | 10 +- tests/WrapOnMethod.spec.ts | 18 +- tests/wrapFunction.spec.ts | 410 +++++++++++++++++++++++++++++++++++++ 7 files changed, 530 insertions(+), 45 deletions(-) create mode 100644 tests/wrapFunction.spec.ts diff --git a/src/index.ts b/src/index.ts index b964bc8..733dee1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ -// Internal (not exported): wrap-on-method.ts, wrap-on-class.ts +// Internal (not exported): wrap-on-class.ts +export { wrapMethod as wrapFunction } from './wrap-on-method'; +export type { WrapMethodOptions } from './wrap-on-method'; export * from './wrap.decorator'; export * from './effect.decorator'; diff --git a/src/wrap-on-class.ts b/src/wrap-on-class.ts index e7c2bce..e7ccc70 100644 --- a/src/wrap-on-class.ts +++ b/src/wrap-on-class.ts @@ -3,7 +3,7 @@ * * Iterates `Object.getOwnPropertyNames(target.prototype)`, skipping the * constructor, non-function values, getters/setters, methods already wrapped - * by {@link WrapOnMethod} (detected via {@link WRAP_APPLIED_KEY}), and + * by {@link WrapOnMethod} (detected via {@link WRAP_KEY}), and * methods excluded via an optional `exclusionKey` symbol. * * @module wrap-on-class @@ -11,7 +11,7 @@ import { getMeta } from './set-meta.decorator'; import type { WrapFn } from './hook.types'; -import { WrapOnMethod, WRAP_APPLIED_KEY } from './wrap-on-method'; +import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; /** * Class decorator factory that wraps every eligible prototype method with @@ -28,7 +28,7 @@ import { WrapOnMethod, WRAP_APPLIED_KEY } from './wrap-on-method'; * @param wrapFn - Factory forwarded to {@link WrapOnMethod} for each * eligible method * @param exclusionKey - Symbol used to detect already-decorated and excluded - * methods. Defaults to {@link WRAP_APPLIED_KEY}. Pass a + * methods. Defaults to {@link WRAP_KEY}. Pass a * custom symbol to isolate this decorator from other * Wrap-based decorators. * @returns A standard `ClassDecorator` @@ -51,7 +51,7 @@ import { WrapOnMethod, WRAP_APPLIED_KEY } from './wrap-on-method'; */ export const WrapOnClass = ( wrapFn: WrapFn, - exclusionKey: symbol = WRAP_APPLIED_KEY, + exclusionKey: symbol = WRAP_KEY, ): ClassDecorator => { const methodDecorator = WrapOnMethod(wrapFn, exclusionKey); diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts index e620daa..c8bcf3c 100644 --- a/src/wrap-on-method.ts +++ b/src/wrap-on-method.ts @@ -9,17 +9,17 @@ import type { WrapFn, WrapContext } from './hook.types'; * at the method level, preventing double-wrapping when both class-level * and method-level decorators are applied. */ -export const WRAP_APPLIED_KEY: unique symbol = Symbol('wrapApplied'); +export const WRAP_KEY: unique symbol = Symbol('wrap'); + /** * Core method decorator factory that wraps `descriptor.value` using a * user-provided {@link WrapFn} factory. * - * The wrapped function preserves `this` context by binding the original - * method to the runtime `this` on every invocation. After wrapping, the - * exclusion key sentinel is set on the descriptor via `setMeta`, and any - * existing `_symMeta` metadata from the original function is copied to - * the wrapper. + * Internally delegates to {@link wrapMethod} for the per-call wrapping + * logic. After wrapping, the exclusion key sentinel is set on the + * descriptor via `setMeta`, and any existing `_symMeta` metadata from + * the original function is copied to the wrapper. * * @typeParam R - The return type of the decorated method * @param wrapFn - Factory called per invocation with the `this`-bound @@ -27,7 +27,7 @@ export const WRAP_APPLIED_KEY: unique symbol = Symbol('wrapApplied'); * replacement function that receives the actual arguments. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default - * {@link WRAP_APPLIED_KEY}. This allows different + * {@link WRAP_KEY}. This allows different * Wrap-based decorators to use independent markers that * do not interfere with each other during class-level * decoration. @@ -46,7 +46,7 @@ export const WRAP_APPLIED_KEY: unique symbol = Symbol('wrapApplied'); */ export const WrapOnMethod = ( wrapFn: WrapFn, - exclusionKey: symbol = WRAP_APPLIED_KEY, + exclusionKey: symbol = WRAP_KEY, ): MethodDecorator => { return ( _target: object, @@ -58,22 +58,11 @@ export const WrapOnMethod = ( // Extract parameter names at decoration time (once, not per-call) const parameterNames = getParameterNames(originalMethod); - const wrapped = function (this: object, ...args: unknown[]): unknown { - const boundMethod = originalMethod.bind(this); - const className = (this.constructor as { name: string }).name ?? ''; - - const wrapContext: WrapContext = { - target: this, - propertyKey, - parameterNames, - className, - descriptor, - }; - - const innerFn = wrapFn(boundMethod, wrapContext); - - return innerFn(...args); - }; + const wrapped = wrapMethod(originalMethod, wrapFn, { + parameterNames, + propertyKey, + descriptor, + }); copySymMeta(originalMethod, wrapped); @@ -85,6 +74,90 @@ export const WrapOnMethod = ( }; }; + +/** + * Options describing the method being wrapped by {@link wrapMethod}. + * + * Groups the decoration-time metadata that {@link wrapMethod} needs + * to build a {@link WrapContext} on every invocation. + */ +export interface WrapMethodOptions { + /** Parameter names extracted from the original function signature. */ + parameterNames: string[]; + /** The property key of the method being wrapped. */ + propertyKey: string | symbol; + /** The property descriptor of the method being wrapped. */ + descriptor: PropertyDescriptor; +} + +/** + * Derives the class name from an object's constructor, returning an + * empty string when the constructor lacks a `name` property. + */ +const getClassName = (instance: object): string => { + const ctor = instance.constructor; + + return ctor?.name ?? ''; +}; + +/** + * Wraps a plain method with a {@link WrapFn} factory, producing a new + * function that builds a {@link WrapContext} on every call and delegates + * to the wrapper. + * + * This is the standalone (non-decorator) counterpart of {@link WrapOnMethod}. + * It can be used directly to wrap any function without relying on the + * decorator syntax. + * + * The wrapper function preserves `this` context by binding the original + * method to the runtime `this` on every invocation, and invokes the + * {@link WrapFn} per call (not at wrap time). + * + * @typeParam R - The return type produced by the wrapper + * @param originalMethod - The function to wrap + * @param wrapFn - Factory called per invocation with the `this`-bound + * original method and a {@link WrapContext}. Returns + * the replacement function that receives the actual + * arguments. + * @param options - Decoration-time metadata for the method being wrapped + * @returns A function that, when called, constructs a {@link WrapContext}, + * invokes `wrapFn`, and delegates to the returned inner function + * + * @example + * ```ts + * const wrapped = wrapMethod(originalFn, myWrapFn, { + * parameterNames: ['a', 'b'], + * propertyKey: 'add', + * descriptor, + * }); + * const result = wrapped.call(instance, 1, 2); + * ``` + */ +export const wrapMethod = ( + originalMethod: (...args: unknown[]) => unknown, + wrapFn: WrapFn, + options: WrapMethodOptions, +): ((this: object, ...args: unknown[]) => unknown) => { + const { parameterNames, propertyKey, descriptor } = options; + + return function (this: object, ...args: unknown[]): unknown { + const boundMethod = originalMethod.bind(this); + const className = getClassName(this); + + const wrapContext: WrapContext = { + target: this, + propertyKey, + parameterNames, + className, + descriptor, + }; + + const innerFn = wrapFn(boundMethod, wrapContext); + + return innerFn(...args); + }; +}; + /** * Copies the `_symMeta` Map from the original function to a new function. * diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts index b5cca87..192b2d6 100644 --- a/tests/Wrap.spec.ts +++ b/tests/Wrap.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Wrap } from '../src/wrap.decorator'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; -import { WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import { WRAP_KEY } from '../src/wrap-on-method'; import type { WrapFn, WrapContext } from '../src/hook.types'; describe('Wrap', () => { @@ -39,7 +39,7 @@ describe('Wrap', () => { TestService.prototype, 'doWork', ); - expect(getMeta(WRAP_APPLIED_KEY, descriptor!)).toBe(true); + expect(getMeta(WRAP_KEY, descriptor!)).toBe(true); }); }); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts index 7c2ca22..97b5cb0 100644 --- a/tests/WrapOnClass.spec.ts +++ b/tests/WrapOnClass.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { WrapOnClass } from '../src/wrap-on-class'; -import { WrapOnMethod, WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; import type { WrapFn, WrapContext } from '../src/hook.types'; @@ -302,7 +302,7 @@ describe('WrapOnClass', () => { @WrapOnClass(wrapFn) class TestService { - @SetMeta(WRAP_APPLIED_KEY, true) + @SetMeta(WRAP_KEY, true) excluded() { return 'excluded'; } @@ -446,7 +446,7 @@ describe('WrapOnClass', () => { @WrapOnClass(wrapFn) class TestService { - @SetMeta(WRAP_APPLIED_KEY, true) + @SetMeta(WRAP_KEY, true) excluded() { return 'excluded'; } @@ -480,7 +480,7 @@ describe('WrapOnClass', () => { TestService.prototype, 'doWork', )!; - expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBe(true); + expect(getMeta(WRAP_KEY, descriptor)).toBe(true); }); }); @@ -530,7 +530,7 @@ describe('WrapOnClass', () => { // Custom key should be set expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); // Default WRAP_APPLIED_KEY should NOT be set since custom key was used - expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBeUndefined(); + expect(getMeta(WRAP_KEY, descriptor)).toBeUndefined(); }); it('should allow different WrapOnClass decorators with different keys', () => { diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts index f2f4aea..6ee6d76 100644 --- a/tests/WrapOnMethod.spec.ts +++ b/tests/WrapOnMethod.spec.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi } from 'vitest'; -import { WrapOnMethod, WRAP_APPLIED_KEY } from '../src/wrap-on-method'; +import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { getMeta, SetMeta } from '../src/set-meta.decorator'; import type { WrapFn, WrapContext } from '../src/hook.types'; describe('WrapOnMethod', () => { - describe('WRAP_APPLIED_KEY', () => { + describe('WRAP_KEY', () => { it('should be a unique symbol', () => { - expect(typeof WRAP_APPLIED_KEY).toBe('symbol'); - expect(WRAP_APPLIED_KEY.toString()).toContain('wrapApplied'); + expect(typeof WRAP_KEY).toBe('symbol'); + expect(WRAP_KEY.toString()).toContain('wrap'); }); }); @@ -253,7 +253,7 @@ describe('WrapOnMethod', () => { }); describe('exclusion key', () => { - it('should set WRAP_APPLIED_KEY as default exclusion key', () => { + it('should set WRAP_KEY as default exclusion key', () => { const wrapFn: WrapFn = (method, _context) => { return (...args: unknown[]) => method(...args); }; @@ -270,7 +270,7 @@ describe('WrapOnMethod', () => { 'doWork', ); - expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBe(true); + expect(getMeta(WRAP_KEY, descriptor)).toBe(true); }); it('should use custom exclusion key when provided', () => { @@ -295,7 +295,7 @@ describe('WrapOnMethod', () => { expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); }); - it('should NOT set default WRAP_APPLIED_KEY when custom key is provided', () => { + it('should NOT set default WRAP_KEY when custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); const wrapFn: WrapFn = (method, _context) => { @@ -314,9 +314,9 @@ describe('WrapOnMethod', () => { 'doWork', ); - // Only the custom key should be set, not the default WRAP_APPLIED_KEY + // Only the custom key should be set, not the default WRAP_KEY expect(getMeta(CUSTOM_KEY, descriptor)).toBe(true); - expect(getMeta(WRAP_APPLIED_KEY, descriptor)).toBeUndefined(); + expect(getMeta(WRAP_KEY, descriptor)).toBeUndefined(); }); }); diff --git a/tests/wrapFunction.spec.ts b/tests/wrapFunction.spec.ts new file mode 100644 index 0000000..37e9217 --- /dev/null +++ b/tests/wrapFunction.spec.ts @@ -0,0 +1,410 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { wrapMethod } from '../src/wrap-on-method'; +import type { WrapFn, WrapContext } from '../src/hook.types'; + +/** + * Helper that simulates how {@link WrapOnMethod} extracts the original + * method from a descriptor. The cast mirrors `descriptor.value as (...args: unknown[]) => unknown` + * in `WrapOnMethod`, so we test `wrapFunction` with the same input shape. + */ +const asMethod = (fn: Function): ((...args: unknown[]) => unknown) => + fn as (...args: unknown[]) => unknown; + +describe('wrapFunction', () => { + describe('basic wrapping', () => { + it('should return a function that invokes wrapFn per call', () => { + const wrapFnSpy = vi.fn((method, _context) => { + return (...args: unknown[]) => method(...args); + }); + + function greet(name: string) { + return `hello ${name}`; + } + + const original = asMethod(greet); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + + const wrapped = wrapMethod(original, wrapFnSpy, { + parameterNames: ['name'], + propertyKey: 'greet', + descriptor, + }); + + expect(wrapFnSpy).not.toHaveBeenCalled(); + + const instance = { constructor: { name: 'TestService' } }; + const result = wrapped.call(instance, 'world'); + + expect(result).toBe('hello world'); + expect(wrapFnSpy).toHaveBeenCalledTimes(1); + }); + + it('should invoke wrapFn on every call, not just the first', () => { + let callCount = 0; + + const wrapFn: WrapFn = (method, _context) => { + callCount++; + return (...args: unknown[]) => method(...args); + }; + + function doWork() { + return 42; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'doWork', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + + expect(callCount).toBe(0); + + wrapped.call(instance); + expect(callCount).toBe(1); + + wrapped.call(instance); + expect(callCount).toBe(2); + + wrapped.call(instance); + expect(callCount).toBe(3); + }); + + it('should return the result from the inner function', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => { + const result = method(...args) as number; + return result * 2; + }; + }; + + function compute(x: number) { + return x + 1; + } + + const original = asMethod(compute); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['x'], + propertyKey: 'compute', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + expect(wrapped.call(instance, 5)).toBe(12); // (5+1)*2 + }); + }); + + describe('this binding', () => { + it('should bind original method to the correct this context', () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + const original: (...args: unknown[]) => unknown = function ( + this: { prefix: string }, + name: unknown, + ) { + return `${this.prefix}, ${name}`; + }; + + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['name'], + propertyKey: 'greet', + descriptor, + }); + + const instance = { prefix: 'Hello', constructor: { name: 'TestService' } }; + expect(wrapped.call(instance, 'world')).toBe('Hello, world'); + }); + + it('should pass a pre-bound method that works without explicit this', () => { + let capturedMethod: ((...args: unknown[]) => unknown) | undefined; + + const wrapFn: WrapFn = (method, _context) => { + capturedMethod = method; + return (...args: unknown[]) => method(...args); + }; + + const original: (...args: unknown[]) => unknown = function ( + this: { value: string }, + ) { + return this.value; + }; + + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'getValue', + descriptor, + }); + + const instance = { value: 'instance-data', constructor: { name: 'TestService' } }; + wrapped.call(instance); + + // The captured method should be pre-bound to the instance + expect(capturedMethod).toBeDefined(); + expect(capturedMethod!()).toBe('instance-data'); + }); + }); + + describe('WrapContext fields', () => { + it('should provide all expected context fields', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + function greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + + const original = asMethod(greet); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['name', 'greeting'], + propertyKey: 'greet', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance, 'world', 'hi'); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.target).toBe(instance); + expect(capturedContext!.propertyKey).toBe('greet'); + expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); + expect(capturedContext!.className).toBe('TestService'); + expect(capturedContext!.descriptor).toBe(descriptor); + }); + + it('should provide className from this.constructor.name', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + function doWork() { + return 'done'; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'doWork', + descriptor, + }); + + const instance = { constructor: { name: 'MySpecialService' } }; + wrapped.call(instance); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.className).toBe('MySpecialService'); + }); + + it('should NOT include args or argsObject in WrapContext', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + function doWork(x: number) { + return x; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['x'], + propertyKey: 'doWork', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance, 42); + + expect(capturedContext).toBeDefined(); + expect('args' in capturedContext!).toBe(false); + expect('argsObject' in capturedContext!).toBe(false); + }); + + it('should return empty string for className when constructor has no name', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (method, context) => { + capturedContext = context; + return (...args: unknown[]) => method(...args); + }; + + function doWork() { + return 'done'; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'doWork', + descriptor, + }); + + // Instance with a constructor that lacks a name property + const instance = { constructor: {} }; + wrapped.call(instance as object); + + expect(capturedContext).toBeDefined(); + expect(capturedContext!.className).toBe(''); + }); + }); + + describe('parameter names reuse', () => { + it('should reuse the same parameterNames reference across calls', () => { + const contexts: WrapContext[] = []; + + const wrapFn: WrapFn = (method, context) => { + contexts.push(context); + return (...args: unknown[]) => method(...args); + }; + + function calculate(price: number, tax: number) { + return price + tax; + } + + const paramNames = ['price', 'tax']; + const original = asMethod(calculate); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: paramNames, + propertyKey: 'calculate', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance, 100, 10); + wrapped.call(instance, 200, 20); + + expect(contexts[0].parameterNames).toEqual(['price', 'tax']); + expect(contexts[1].parameterNames).toEqual(['price', 'tax']); + // Same reference passed each time (extracted once, reused) + expect(contexts[0].parameterNames).toBe(contexts[1].parameterNames); + }); + }); + + describe('async methods', () => { + it('should work with async methods', async () => { + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + async function fetchData(id: number): Promise { + return `data-${id}`; + } + + const original = asMethod(fetchData); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['id'], + propertyKey: 'fetchData', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + const result = await wrapped.call(instance, 42); + + expect(result).toBe('data-42'); + }); + + it('should allow async wrapper to modify async results', async () => { + const wrapFn: WrapFn> = (method, _context) => { + return async (...args: unknown[]) => { + const result = (await method(...args)) as string; + return `modified: ${result}`; + }; + }; + + async function fetchData(id: number): Promise { + return `data-${id}`; + } + + const original = asMethod(fetchData); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['id'], + propertyKey: 'fetchData', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + const result = await wrapped.call(instance, 42); + + expect(result).toBe('modified: data-42'); + }); + + it('should propagate async errors from the original method', async () => { + const asyncError = new Error('async failure'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + async function failingAsync() { + throw asyncError; + } + + const original = asMethod(failingAsync); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'failingAsync', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + await expect(wrapped.call(instance)).rejects.toThrow(asyncError); + }); + }); + + describe('sync error propagation', () => { + it('should propagate sync errors from the original method', () => { + const syncError = new Error('sync failure'); + + const wrapFn: WrapFn = (method, _context) => { + return (...args: unknown[]) => method(...args); + }; + + function failing(): never { + throw syncError; + } + + const original = asMethod(failing); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'failing', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + expect(() => wrapped.call(instance)).toThrow(syncError); + }); + }); + + describe('export from barrel', () => { + it('should be importable from the main index', async () => { + const indexModule = await import('../src/index'); + expect(typeof indexModule.wrapFunction).toBe('function'); + }); + }); +}); From 430306b619286288690bf679d8ebbb618f04c793 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 23:52:35 +0200 Subject: [PATCH 08/10] refactor: make factory functions call at decoration time BREAKING CHANGE: removed effect-on-class and effect-on-method --- README.md | 92 ++++++++---- src/effect.decorator.ts | 80 +++------- src/hook.types.ts | 63 ++++---- src/index.ts | 2 +- src/wrap-on-class.ts | 6 +- src/wrap-on-method.ts | 95 ++++++++---- src/wrap.decorator.ts | 17 ++- tests/Effect.spec.ts | 201 +++++++++++++++++++++++++ tests/Wrap.spec.ts | 184 +++++++++++++---------- tests/WrapOnClass.spec.ts | 181 +++++++++++----------- tests/WrapOnMethod.spec.ts | 281 +++++++++++++++++++++++++---------- tests/hook-types.spec.ts | 116 +++++++++++---- tests/wrapFunction.spec.ts | 297 ++++++++++++++++++++++++++++--------- 13 files changed, 1122 insertions(+), 493 deletions(-) diff --git a/README.md b/README.md index 1899f4a..0aa17b0 100644 --- a/README.md +++ b/README.md @@ -51,16 +51,18 @@ npm install base-decorators ### Using Wrap -`Wrap` is the foundational primitive. You receive the original method and a context, and return a replacement function: +`Wrap` is the foundational primitive. You receive a `WrapContext` at decoration time and return a replacement function: ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; +import type { WrapContext, InvocationContext } from 'base-decorators'; -const Log = () => Wrap((method, context: WrapContext) => { +const Log = () => Wrap((context: WrapContext) => { + // Outer function: called once at decoration time console.log('decorating', context.propertyKey); - return (...args: unknown[]) => { + return ({args}, method) => { + // Inner function: called on every invocation console.log('called with', args); const result = method(...args); @@ -76,12 +78,16 @@ class Calculator { return a + b; } } +// logs: "decorating add" (at decoration time) const calc = new Calculator(); -// logs: "decorating add" calc.add(2, 3); // logs: "called with [2, 3]" // logs: "returned 5" + +calc.add(3, 1); +// logs: "called with [3, 1]" +// logs: "returned 4" ``` ### Using Effect (lifecycle hooks) @@ -109,15 +115,17 @@ calc.add(2, 3); ## How It Works -**`Wrap`** accepts a factory function that receives the original method (already bound to `this`) and a `WrapContext`, and returns a replacement function. You control the entire execution flow: +**`Wrap`** accepts a factory function that receives a `WrapContext` at decoration time and returns an inner function. The inner function is called on every invocation with an `InvocationContext` and the `this`-bound original method. You control the entire execution flow: ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; +import type { WrapContext, InvocationContext } from 'base-decorators'; -const Log = () => Wrap((method, context: WrapContext) => { - return (...args: unknown[]) => { - console.log(`${context.className}.${String(context.propertyKey)} called`); +const Log = () => Wrap((context: WrapContext) => { + // Outer: called once at decoration time. WrapContext has propertyKey, parameterNames, descriptor. + return ({ args, className }: InvocationContext, method) => { + // Inner: called on every invocation. InvocationContext extends WrapContext with target, className, args, argsObject. + console.log(`${className}.${String(context.propertyKey)} called`); return method(...args); }; }); @@ -205,14 +213,14 @@ class Service { ### Async hooks -All decorators work naturally with async methods. Return an async replacement function to handle promises: +All decorators work naturally with async methods. Return an async inner function to handle promises: ```typescript import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; +import type { WrapContext, InvocationContext } from 'base-decorators'; -const AsyncTimer = () => Wrap((method, context: WrapContext) => { - return async (...args: unknown[]) => { +const AsyncTimer = () => Wrap((context: WrapContext) => { + return async ({ args }, method) => { const start = Date.now(); const result = await method(...args); @@ -411,17 +419,40 @@ Each hook receives a context object. All hooks are optional. Each hook has a cor | `onError` | When the method throws an error | Replaces the thrown error (return a value or re-throw) | | `finally` | After `onReturn` or `onError`, regardless of outcome | Ignored | -### HookContext +### WrapContext + +Available in the **outer** factory function passed to `Wrap`. Contains only decoration-time fields -- fields that vary per call (target, className, args) are absent here. ```typescript -interface HookContext { - args: unknown[]; // raw arguments - argsObject: Record | undefined; // mapped parameter names +interface WrapContext { + propertyKey: string | symbol; // method name + parameterNames: string[]; // extracted parameter names + descriptor: PropertyDescriptor; // method descriptor +} +``` + +### InvocationContext + +Passed to the **inner** function returned by your `Wrap` factory. Extends `WrapContext` with per-call runtime fields, so all decoration-time fields are also available here. + +```typescript +interface InvocationContext extends WrapContext { target: object; // class instance (this) - propertyKey: string | symbol; // method name - parameterNames: string[]; // extracted parameter names className: string; // runtime class name - descriptor: PropertyDescriptor; // method descriptor + args: unknown[]; // raw arguments + argsObject: Record | undefined; // mapped parameter names + // Plus all WrapContext fields: propertyKey, parameterNames, descriptor +} +``` + +### HookContext + +Passed to every `Effect` lifecycle hook. Equivalent to `InvocationContext` -- all seven fields are available. + +```typescript +interface HookContext extends InvocationContext { + // All fields from WrapContext: propertyKey, parameterNames, descriptor + // All fields from InvocationContext: target, className, args, argsObject } ``` @@ -445,14 +476,14 @@ class Service { ### Factory Hooks -In addition to a static hooks object, `Effect` accepts a **factory function** that receives the current `HookContext` and returns an `EffectHooks` object. This is useful when you need to decide which hooks (or what behavior) to apply at runtime based on the method being invoked. +In addition to a static hooks object, `Effect` accepts a **factory function** that receives a `WrapContext` and returns an `EffectHooks` object. This is useful when you need to decide which hooks (or what behavior) to apply based on the decorated method. ```typescript import { Effect } from 'base-decorators'; -import type { HookContext, EffectHooks } from 'base-decorators'; +import type { WrapContext, EffectHooks } from 'base-decorators'; -const DynamicHooks = Effect(({className}: HookContext): EffectHooks => { - if (className === 'DebugService') { +const DynamicHooks = Effect(({propertyKey}: WrapContext): EffectHooks => { + if (String(propertyKey).startsWith('debug')) { return { onInvoke: ({ args }) => console.log('debug invoke', args), onReturn: ({ result }) => result, @@ -466,13 +497,13 @@ const DynamicHooks = Effect(({className}: HookContext): EffectHooks => { class DebugService { @DynamicHooks() - compute(value: number) { + debugCompute(value: number) { return value * 2; } } ``` -The factory is called **once per method invocation**, immediately before `onInvoke`, so it can inspect `args`, `propertyKey`, `className`, and every other field on `HookContext`. +The factory is called **once at decoration time** with the `WrapContext` containing `propertyKey`, `parameterNames`, and `descriptor`. The resolved hooks are reused for every subsequent call. Each resolved hook still receives the full `HookContext` (including `args`, `argsObject`, `target`, and `className`) on every invocation. ## API Reference @@ -487,9 +518,10 @@ The factory is called **once per method invocation**, immediately before `onInvo | `OnReturnHook` | Decorator | Convenience hook for `onReturn` | | `OnErrorHook` | Decorator | Convenience hook for `onError` | | `FinallyHook` | Decorator | Convenience hook for `finally` | -| `WrapContext` | Type | Context passed to `Wrap` wrapper functions (target, propertyKey, parameterNames, className, descriptor) | -| `WrapFn` | Type | Wrapper function signature: `(method, context: WrapContext) => (...args) => R` | -| `HookContext` | Type | Context passed to `Effect` hooks (extends `WrapContext` with args and argsObject) | +| `WrapContext` | Type | Decoration-time context passed to `Wrap` outer factory (propertyKey, parameterNames, descriptor) | +| `InvocationContext` | Type | Per-call context extending `WrapContext` with runtime fields (target, className, args, argsObject) | +| `WrapFn` | Type | Wrapper function signature: `(context: WrapContext) => (context: InvocationContext, method) => R` | +| `HookContext` | Type | Context passed to `Effect` hooks -- equivalent to `InvocationContext` with all fields | | `EffectHooks` | Type | Lifecycle hooks object for `Effect` (onInvoke, onReturn, onError, finally) | ## Advanced Example diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index 350f25f..073bcc4 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -3,6 +3,7 @@ import type { EffectHooks, HookContext, HooksOrFactory, + InvocationContext, UnwrapPromise, WrapContext, } from './hook.types'; @@ -22,9 +23,10 @@ import type { * * @typeParam R - The return type expected from lifecycle hooks * @param hooks - Lifecycle callbacks (all optional) or a factory - * function that receives a {@link HookContext} and - * returns hooks. The factory is called once per - * method invocation, before any hooks fire. + * function that receives a {@link WrapContext} and + * returns hooks. The factory is called **once at + * decoration time**. The resolved hooks are reused + * for every subsequent call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * `WRAP_APPLIED_KEY`. This allows different @@ -52,22 +54,21 @@ import type { export const Effect = ( hooks: HooksOrFactory, exclusionKey?: symbol, -): ClassDecorator & MethodDecorator => - Wrap(( - boundMethod: (...args: unknown[]) => unknown, - wrapContext: WrapContext, - ) => { - return (...args: unknown[]): unknown => { - const argsObject = buildArgsObject(wrapContext.parameterNames, args); - - const hookContext: HookContext = { ...wrapContext, args, argsObject }; - - const resolvedHooks = resolveHooks(hooks, hookContext); +): ClassDecorator & MethodDecorator => + Wrap((wrapContext: WrapContext) => { + // Resolve hooks ONCE at decoration time. + // When hooks is a factory, it receives decoration-time WrapContext. + const resolvedHooks = resolveHooks(hooks, wrapContext); + + return ( + invocationContext: InvocationContext, + boundMethod: (...args: unknown[]) => unknown, + ): unknown => { + const hookContext: HookContext = { ...invocationContext }; const executeMethod = attachHooks( boundMethod, - wrapContext.target, - args, + invocationContext.args, hookContext, resolvedHooks, ); @@ -85,56 +86,20 @@ export const Effect = ( }, exclusionKey); /** - * Builds an object mapping parameter names to their values. - * - * Creates a record where keys are parameter names and values are the - * corresponding argument values passed to the function. - * - * @param parameterNames - Array of parameter names - * @param args - Array of argument values - * @returns Object mapping parameter names to values, or undefined when empty - * - * @example - * buildArgsObject(['id', 'name'], [1, 'John']) - * // Returns: { id: 1, name: 'John' } - * - * @internal - */ -export const buildArgsObject = ( - parameterNames: string[], - args: unknown[], -): Record | undefined => { - if (args.length === 0 && parameterNames.length === 0) { - return undefined; - } - - const argsObject: Record = {}; - - parameterNames.forEach((paramName, index) => { - if (index < args.length) { - argsObject[paramName] = args[index]; - } - }); - - return argsObject; -}; - -/** - * Returns a thunk that runs the original method and applies sync/async lifecycle hooks. + * Returns a thunk that runs the bound method and applies sync/async lifecycle hooks. * * Kept as a thunk so async `onInvoke` can defer execution via `.then()`. * `finally` is applied inline on sync paths to avoid double-calling when * `onReturn` or `onError` throw. */ const attachHooks = ( - originalMethod: (...args: unknown[]) => unknown, - thisArg: object, + boundMethod: (...args: unknown[]) => unknown, args: unknown[], context: HookContext, hooks: EffectHooks, ): (() => unknown) => () => { try { - const result = originalMethod.apply(thisArg, args); + const result = boundMethod(...args); if (result instanceof Promise) { return chainAsyncHooks(result, context, hooks); @@ -164,11 +129,12 @@ const attachHooks = ( * Resolves hooks from a static object or factory function. * * When `hooksOrFactory` is a function, it is called with the provided - * context to produce the hooks. Otherwise, the static hooks are returned. + * decoration-time context to produce the hooks. Otherwise, the static + * hooks are returned as-is. */ const resolveHooks = ( hooksOrFactory: HooksOrFactory, - context: HookContext, + context: WrapContext, ): EffectHooks => { if (typeof hooksOrFactory === 'function') { return hooksOrFactory(context); diff --git a/src/hook.types.ts b/src/hook.types.ts index 4a10dce..922bcf7 100644 --- a/src/hook.types.ts +++ b/src/hook.types.ts @@ -2,53 +2,59 @@ export type HookArgs = Record | undefined; /** - * Decoration-time and runtime context available to every wrapper. + * Decoration-time context available to every wrapper factory. * - * Contains the fields that are known at decoration time (propertyKey, - * parameterNames, descriptor) plus fields resolved at runtime (target, - * className). Does NOT include per-call argument data -- that is added - * by {@link HookContext} for lifecycle-hook consumers. + * Contains only the fields known at decoration time. Runtime fields + * (target, className) and per-call argument data are provided + * separately via {@link InvocationContext} and {@link HookContext}. */ export interface WrapContext { - /** The `this` target object (class instance). */ - target: object; /** The property key of the decorated method. */ propertyKey: string | symbol; /** Parameter names extracted from the original function signature. */ parameterNames: string[]; - /** Runtime class name derived from `this.constructor.name`. */ - className: string; /** The property descriptor of the decorated method. */ descriptor: PropertyDescriptor; } +/** + * Per-call context passed to the inner function returned by a {@link WrapFn}. + * + * Extends {@link WrapContext} with runtime fields that change on each + * invocation: the `this` target, the derived class name, and the + * raw/mapped arguments. + */ +export interface InvocationContext extends WrapContext { + /** The `this` target object (class instance). */ + target: object; + /** Runtime class name derived from `this.constructor.name`. */ + className: string; + /** Raw arguments array passed to the method. */ + args: unknown[]; + /** Pre-built args object mapping parameter names to their values. */ + argsObject: HookArgs; +} + /** * Factory function accepted by the Wrap decorator. * - * Receives the original (this-bound) method and a {@link WrapContext}, - * and returns a replacement function that is called with the actual - * arguments at invocation time. + * Called **once at decoration time** with a {@link WrapContext}. Returns an + * inner function that is called on every invocation with an + * {@link InvocationContext} and the `this`-bound original method. * - * @typeParam R - The return type produced by the replacement function + * @typeParam R - The return type produced by the inner function */ export type WrapFn = ( - method: (...args: unknown[]) => unknown, context: WrapContext, -) => (...args: unknown[]) => R; +) => (context: InvocationContext, method: (...args: unknown[]) => unknown) => R; /** * Shared context passed to every lifecycle hook. * - * Extends {@link WrapContext} with per-call argument data: the raw - * arguments array and the pre-built args object mapping parameter - * names to their values. + * Equivalent to {@link InvocationContext} which already includes all + * {@link WrapContext} fields plus per-call runtime data. */ -export interface HookContext extends WrapContext { - /** Raw arguments array passed to the method. */ - args: unknown[]; - /** Pre-built args object mapping parameter names to their values. */ - argsObject: HookArgs; -} +export interface HookContext extends InvocationContext {} /** Extracts the resolved type from a Promise, or returns the type itself. */ export type UnwrapPromise = T extends Promise ? U : T; @@ -130,13 +136,14 @@ export interface EffectHooks { /** * Accepts either a static hooks object or a factory function that - * produces hooks at runtime from the invocation context. + * produces hooks from the decoration-time context. * - * When a factory is provided, it is called once per method invocation - * inside the wrapper function, before any hooks fire. + * When a factory is provided, it is called **once at decoration time** + * with the {@link WrapContext}. The resolved hooks are reused for + * every subsequent call. * * @typeParam R - The return type of the decorated method */ export type HooksOrFactory = | EffectHooks - | ((context: HookContext) => EffectHooks); + | ((context: WrapContext) => EffectHooks); diff --git a/src/index.ts b/src/index.ts index 733dee1..21b6d16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ // Internal (not exported): wrap-on-class.ts -export { wrapMethod as wrapFunction } from './wrap-on-method'; +export { wrapMethod as wrapFunction, buildArgsObject } from './wrap-on-method'; export type { WrapMethodOptions } from './wrap-on-method'; export * from './wrap.decorator'; export * from './effect.decorator'; diff --git a/src/wrap-on-class.ts b/src/wrap-on-class.ts index e7ccc70..a7b553a 100644 --- a/src/wrap-on-class.ts +++ b/src/wrap-on-class.ts @@ -37,9 +37,9 @@ import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; * ```ts * const LOG_KEY = Symbol('log'); * - * \@WrapOnClass((method, ctx) => (...args) => { - * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); - * return method(...args); + * \@WrapOnClass((ctx) => (invCtx, method) => { + * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); + * return method(...invCtx.args); * }, LOG_KEY) * class Service { * doWork() { return 42; } diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts index c8bcf3c..c3becb4 100644 --- a/src/wrap-on-method.ts +++ b/src/wrap-on-method.ts @@ -1,6 +1,6 @@ import { setMeta, SYM_META_PROP } from './set-meta.decorator'; import { getParameterNames } from './getParameterNames'; -import type { WrapFn, WrapContext } from './hook.types'; +import type { WrapFn, WrapContext, InvocationContext } from './hook.types'; /** * Symbol sentinel set on every function wrapped by {@link WrapOnMethod}. @@ -22,9 +22,10 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * the original function is copied to the wrapper. * * @typeParam R - The return type of the decorated method - * @param wrapFn - Factory called per invocation with the `this`-bound - * original method and a {@link WrapContext}. Returns the - * replacement function that receives the actual arguments. + * @param wrapFn - Factory called once at decoration time with a + * {@link WrapContext}. Returns the inner function that + * receives an {@link InvocationContext} and the + * `this`-bound original method on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * {@link WRAP_KEY}. This allows different @@ -36,9 +37,9 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * @example * ```ts * class Service { - * \@WrapOnMethod((method, ctx) => (...args) => { - * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); - * return method(...args); + * \@WrapOnMethod((ctx) => (invCtx, method) => { + * console.log(`${String(ctx.propertyKey)} called with`, invCtx.args); + * return method(...invCtx.args); * }) * doWork(input: string) { return input.toUpperCase(); } * } @@ -100,28 +101,63 @@ const getClassName = (instance: object): string => { return ctor?.name ?? ''; }; +/** + * Builds an object mapping parameter names to their call-time values. + * + * Creates a record where keys are parameter names and values are the + * corresponding argument values passed to the function. Returns + * `undefined` when both arrays are empty. + * + * @param parameterNames - Array of parameter names from the function signature + * @param args - Array of argument values from the current invocation + * @returns Object mapping parameter names to values, or undefined when empty + * + * @example + * buildArgsObject(['id', 'name'], [1, 'John']) + * // Returns: { id: 1, name: 'John' } + */ +export const buildArgsObject = ( + parameterNames: string[], + args: unknown[], +): Record | undefined => { + if (args.length === 0 && parameterNames.length === 0) { + return undefined; + } + + const argsObject: Record = {}; + + parameterNames.forEach((paramName, index) => { + if (index < args.length) { + argsObject[paramName] = args[index]; + } + }); + + return argsObject; +}; + /** * Wraps a plain method with a {@link WrapFn} factory, producing a new - * function that builds a {@link WrapContext} on every call and delegates - * to the wrapper. + * function that delegates to the factory-returned inner function on + * every call. * * This is the standalone (non-decorator) counterpart of {@link WrapOnMethod}. * It can be used directly to wrap any function without relying on the * decorator syntax. * - * The wrapper function preserves `this` context by binding the original - * method to the runtime `this` on every invocation, and invokes the - * {@link WrapFn} per call (not at wrap time). + * The {@link WrapFn} factory is called **once** (immediately) with the + * decoration-time {@link WrapContext}. On each invocation, the original + * method is bound to `this`, an {@link InvocationContext} is built with + * the current arguments, and both are passed to the inner function. * * @typeParam R - The return type produced by the wrapper * @param originalMethod - The function to wrap - * @param wrapFn - Factory called per invocation with the `this`-bound - * original method and a {@link WrapContext}. Returns - * the replacement function that receives the actual - * arguments. + * @param wrapFn - Factory called once at wrap time with a + * {@link WrapContext}. Returns the inner function + * that receives an {@link InvocationContext} and + * the `this`-bound method on each call. * @param options - Decoration-time metadata for the method being wrapped - * @returns A function that, when called, constructs a {@link WrapContext}, - * invokes `wrapFn`, and delegates to the returned inner function + * @returns A function that, when called, binds the original method, builds + * an {@link InvocationContext}, and delegates to the inner function * * @example * ```ts @@ -140,21 +176,30 @@ export const wrapMethod = ( ): ((this: object, ...args: unknown[]) => unknown) => { const { parameterNames, propertyKey, descriptor } = options; + const decorationContext: WrapContext = { + propertyKey, + parameterNames, + descriptor, + }; + + const factoryFn = wrapFn(decorationContext); + return function (this: object, ...args: unknown[]): unknown { + const boundMethod = originalMethod.bind(this); + const className = getClassName(this); + const argsObject = buildArgsObject(parameterNames, args); - const wrapContext: WrapContext = { + const invocationContext: InvocationContext = { + ...decorationContext, target: this, - propertyKey, - parameterNames, className, - descriptor, + args, + argsObject, }; - const innerFn = wrapFn(boundMethod, wrapContext); - - return innerFn(...args); + return factoryFn(invocationContext, boundMethod); }; }; diff --git a/src/wrap.decorator.ts b/src/wrap.decorator.ts index 6f9d871..5f686f8 100644 --- a/src/wrap.decorator.ts +++ b/src/wrap.decorator.ts @@ -20,9 +20,10 @@ import type { WrapFn } from './hook.types'; * present but `descriptor` is `undefined`). * * @typeParam R - The return type expected from the wrapper function - * @param wrapFn - Factory called per invocation with the `this`-bound - * original method and a {@link WrapContext}. Returns the - * replacement function that receives the actual arguments. + * @param wrapFn - Factory called once at decoration time with a + * {@link WrapContext}. Returns the inner function that + * receives an {@link InvocationContext} and the + * `this`-bound original method on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * `WRAP_APPLIED_KEY`. This allows different @@ -35,17 +36,17 @@ import type { WrapFn } from './hook.types'; * ```ts * // Method-level usage * class Service { - * \@Wrap((method, ctx) => (...args) => { - * console.log(`${ctx.className}.${String(ctx.propertyKey)} called`); - * return method(...args); + * \@Wrap((ctx) => (invCtx, method) => { + * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); + * return method(...invCtx.args); * }) * doWork() { return 42; } * } * * // Class-level usage - * \@Wrap((method, ctx) => (...args) => { + * \@Wrap((ctx) => (invCtx, method) => { * console.log(`${String(ctx.propertyKey)} called`); - * return method(...args); + * return method(...invCtx.args); * }) * class AnotherService { * methodA() { return 'a'; } diff --git a/tests/Effect.spec.ts b/tests/Effect.spec.ts index 9ff5636..5334cfe 100644 --- a/tests/Effect.spec.ts +++ b/tests/Effect.spec.ts @@ -305,6 +305,207 @@ describe('Effect', () => { }); }); + describe('factory hook resolution', () => { + it('should call factory ONCE at decoration time, not per invocation', () => { + const factorySpy = vi.fn(() => ({ + onReturn: ({ result }: { result: unknown }) => result, + })); + + class TestService { + @Effect(factorySpy) + doWork() { + return 42; + } + } + + // Factory called at decoration time + expect(factorySpy).toHaveBeenCalledTimes(1); + + const service = new TestService(); + + service.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + + service.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + + service.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + }); + + it('should call factory once at decoration time, reuse for all instances', () => { + const factorySpy = vi.fn(() => ({ + onReturn: ({ result }: { result: unknown }) => result, + })); + + class TestService { + @Effect(factorySpy) + doWork() { + return 42; + } + } + + // Factory called once at decoration time + expect(factorySpy).toHaveBeenCalledTimes(1); + + const serviceA = new TestService(); + const serviceB = new TestService(); + + serviceA.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + + serviceB.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + + serviceA.doWork(); + serviceB.doWork(); + expect(factorySpy).toHaveBeenCalledTimes(1); + }); + + it('should pass WrapContext fields to factory (propertyKey, parameterNames, descriptor)', () => { + let receivedContext: Record | undefined; + + const factory = (ctx: Record) => { + receivedContext = ctx; + return { + onReturn: ({ result }: { result: unknown }) => result, + }; + }; + + class TestService { + @Effect(factory as Parameters[0]) + greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + } + void TestService; + + // Factory called at decoration time + expect(receivedContext).toBeDefined(); + expect(receivedContext!['propertyKey']).toBe('greet'); + expect(receivedContext!['parameterNames']).toEqual(['name', 'greeting']); + expect(receivedContext!['descriptor']).toBeDefined(); + }); + + it('should NOT pass args, argsObject, target, or className to factory', () => { + let receivedContext: Record | undefined; + + const factory = (ctx: Record) => { + receivedContext = ctx; + return {}; + }; + + class TestService { + @Effect(factory as Parameters[0]) + doWork(x: number) { + return x; + } + } + void TestService; + + // Factory is called at decoration time, no runtime fields available + expect(receivedContext).toBeDefined(); + expect(receivedContext!['args']).toBeUndefined(); + expect(receivedContext!['argsObject']).toBeUndefined(); + expect(receivedContext!['target']).toBeUndefined(); + expect(receivedContext!['className']).toBeUndefined(); + }); + + it('should pass full HookContext to each resolved hook per invocation', () => { + const capturedContexts: Record[] = []; + + const factory = () => ({ + onInvoke: (ctx: Record) => { + capturedContexts.push(ctx); + }, + onReturn: ({ result }: { result: unknown }) => result, + }); + + class TestService { + @Effect(factory as Parameters[0]) + greet(name: string) { + return `hello ${name}`; + } + } + + const service = new TestService(); + service.greet('alice'); + service.greet('bob'); + + expect(capturedContexts).toHaveLength(2); + + // First invocation: full HookContext with args + expect(capturedContexts[0]['args']).toEqual(['alice']); + expect(capturedContexts[0]['target']).toBe(service); + expect(capturedContexts[0]['className']).toBe('TestService'); + expect(capturedContexts[0]['propertyKey']).toBe('greet'); + + // Second invocation: different args, same instance + expect(capturedContexts[1]['args']).toEqual(['bob']); + expect(capturedContexts[1]['target']).toBe(service); + }); + + it('should use factory-returned hooks for method execution', () => { + const callOrder: string[] = []; + + const factory = () => ({ + onInvoke: () => callOrder.push('factory-onInvoke'), + onReturn: ({ result }: { result: string }) => { + callOrder.push('factory-onReturn'); + return result; + }, + finally: () => callOrder.push('factory-finally'), + }); + + class TestService { + @Effect(factory as Parameters[0]) + greet(name: string) { + callOrder.push('original'); + return `hello ${name}`; + } + } + + const service = new TestService(); + const result = service.greet('world'); + + expect(result).toBe('hello world'); + expect(callOrder).toEqual([ + 'factory-onInvoke', + 'original', + 'factory-onReturn', + 'factory-finally', + ]); + }); + + it('should work with factory and class-level decoration', () => { + const factorySpy = vi.fn(() => ({ + onReturn: ({ result }: { result: unknown }) => result, + })); + + @Effect(factorySpy) + class TestService { + methodA() { + return 'a'; + } + + methodB() { + return 'b'; + } + } + + // Factory called once per method at decoration time + // (WrapOnClass creates separate WrapOnMethod per method) + expect(factorySpy).toHaveBeenCalledTimes(2); + + const service = new TestService(); + service.methodA(); + service.methodB(); + + // Still 2 (not called again per invocation) + expect(factorySpy).toHaveBeenCalledTimes(2); + }); + }); + describe('unsupported context throws error', () => { it('should throw Error when applied in an unsupported context', () => { const decorator = Effect({}); diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts index 192b2d6..9834d2c 100644 --- a/tests/Wrap.spec.ts +++ b/tests/Wrap.spec.ts @@ -3,13 +3,13 @@ import { describe, it, expect, vi } from 'vitest'; import { Wrap } from '../src/wrap.decorator'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; import { WRAP_KEY } from '../src/wrap-on-method'; -import type { WrapFn, WrapContext } from '../src/hook.types'; +import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; describe('Wrap', () => { describe('applied to a method', () => { it('should delegate to WrapOnMethod and wrap the method', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -26,7 +26,7 @@ describe('Wrap', () => { }); it('should set WRAP_APPLIED_KEY on the method descriptor', () => { - const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); class TestService { @Wrap(wrapFn) @@ -47,10 +47,10 @@ describe('Wrap', () => { it('should delegate to WrapOnClass and wrap all prototype methods', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -76,17 +76,17 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const classWrapFn: WrapFn = (context) => { + return (invCtx, method) => { classCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; - const methodWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const methodWrapFn: WrapFn = (context) => { + return (invCtx, method) => { methodCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -111,7 +111,7 @@ describe('Wrap', () => { }); it('should return the constructor when applied to a class', () => { - const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); @Wrap(wrapFn) class TestService { @@ -128,10 +128,10 @@ describe('Wrap', () => { it('should not wrap getters or setters', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -164,8 +164,8 @@ describe('Wrap', () => { }); it('should not wrap the constructor', () => { - const wrapFnSpy = vi.fn((method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFnSpy = vi.fn((_context) => { + return (invCtx, method) => method(...invCtx.args); }); @Wrap(wrapFnSpy) @@ -181,24 +181,25 @@ describe('Wrap', () => { } } - // wrapFn should not be called during construction - expect(wrapFnSpy).not.toHaveBeenCalled(); + // wrapFn is called at decoration time for each eligible method + expect(wrapFnSpy).toHaveBeenCalledOnce(); const service = new TestService(); expect(service.value).toBe(42); service.doWork(); + // wrapFn still called only once (at decoration time) expect(wrapFnSpy).toHaveBeenCalledOnce(); }); }); - describe('WrapFn receives bound method and WrapContext', () => { - it('should pass WrapContext with all expected fields', () => { + describe('WrapFn receives WrapContext at decoration time', () => { + it('should pass WrapContext with decoration-time fields only', () => { let receivedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { receivedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -207,24 +208,54 @@ describe('Wrap', () => { return input.toUpperCase(); } } + void TestService; - const service = new TestService(); - service.doWork('test'); - + // WrapContext captured at decoration time expect(receivedContext).toBeDefined(); expect(receivedContext!.propertyKey).toBe('doWork'); - expect(receivedContext!.className).toBe('TestService'); - expect(receivedContext!.target).toBe(service); expect(receivedContext!.parameterNames).toEqual(['input']); expect(receivedContext!.descriptor).toBeDefined(); }); + it('should provide target and className via InvocationContext, not WrapContext', () => { + let receivedWrapCtx: WrapContext | undefined; + let receivedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (context) => { + receivedWrapCtx = context; + return (invCtx, method) => { + receivedInvCtx = invCtx; + return method(...invCtx.args); + }; + }; + + class TestService { + @Wrap(wrapFn) + doWork(input: string) { + return input.toUpperCase(); + } + } + + const service = new TestService(); + service.doWork('test'); + + // WrapContext should NOT have target or className + expect(receivedWrapCtx).toBeDefined(); + expect('target' in receivedWrapCtx!).toBe(false); + expect('className' in receivedWrapCtx!).toBe(false); + + // InvocationContext should have target and className + expect(receivedInvCtx).toBeDefined(); + expect(receivedInvCtx!.target).toBe(service); + expect(receivedInvCtx!.className).toBe('TestService'); + }); + it('should NOT include args or argsObject on WrapContext', () => { let receivedContext: Record | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { receivedContext = context as unknown as Record; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -233,21 +264,22 @@ describe('Wrap', () => { return a + b; } } + void TestService; - const service = new TestService(); - service.doWork(1, 2); - + // WrapContext captured at decoration time has no args expect(receivedContext).toBeDefined(); expect(receivedContext!['args']).toBeUndefined(); expect(receivedContext!['argsObject']).toBeUndefined(); }); - it('should pass a this-bound method to WrapFn', () => { + it('should pass a this-bound method per invocation', () => { let receivedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { - receivedMethod = method; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + receivedMethod = method; + return method(...invCtx.args); + }; }; class TestService { @@ -271,8 +303,8 @@ describe('Wrap', () => { }); it('should bind method to the correct instance for each invocation', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -294,8 +326,8 @@ describe('Wrap', () => { describe('sync method through Wrap', () => { it('should wrap a sync method and return its result unchanged', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class Calculator { @@ -310,9 +342,9 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the sync return value', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => { - const result = method(...args) as number; + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + const result = method(...invCtx.args) as number; return result * 10; }; }; @@ -329,10 +361,10 @@ describe('Wrap', () => { }); it('should allow Wrap to intercept arguments for sync methods', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { // Intercept: double all numeric arguments - const doubled = args.map((a) => + const doubled = invCtx.args.map((a) => typeof a === 'number' ? a * 2 : a, ); return method(...doubled); @@ -354,9 +386,9 @@ describe('Wrap', () => { describe('async method through Wrap', () => { it('should wrap an async method and return its resolved value', async () => { - const wrapFn: WrapFn = (method, _context) => { - return async (...args: unknown[]) => { - const result = await method(...args); + const wrapFn: WrapFn = (_context) => { + return async (invCtx, method) => { + const result = await method(...invCtx.args); return result; }; }; @@ -375,9 +407,9 @@ describe('Wrap', () => { }); it('should allow Wrap to modify the async return value', async () => { - const wrapFn: WrapFn = (method, _context) => { - return async (...args: unknown[]) => { - const result = (await method(...args)) as { id: number; name: string }; + const wrapFn: WrapFn = (_context) => { + return async (invCtx, method) => { + const result = (await method(...invCtx.args)) as { id: number; name: string }; return { ...result, modified: true }; }; }; @@ -396,9 +428,9 @@ describe('Wrap', () => { }); it('should propagate errors from async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { - return async (...args: unknown[]) => { - return method(...args); + const wrapFn: WrapFn = (_context) => { + return async (invCtx, method) => { + return method(...invCtx.args); }; }; @@ -421,17 +453,17 @@ describe('Wrap', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFnA: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFnA: WrapFn = (context) => { + return (invCtx, method) => { calls.push(`A:${String(context.propertyKey)}`); - return method(...args); + return method(...invCtx.args); }; }; - const wrapFnB: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFnB: WrapFn = (context) => { + return (invCtx, method) => { calls.push(`B:${String(context.propertyKey)}`); - return method(...args); + return method(...invCtx.args); }; }; @@ -456,17 +488,17 @@ describe('Wrap', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const classWrapFn: WrapFn = (context) => { + return (invCtx, method) => { classCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; - const methodWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const methodWrapFn: WrapFn = (context) => { + return (invCtx, method) => { methodCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -496,10 +528,10 @@ describe('Wrap', () => { const EXCLUSION_KEY = Symbol('noWrap'); const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -525,7 +557,7 @@ describe('Wrap', () => { it('should mark method with exclusionKey when applied at method level', () => { const EXCLUSION_KEY = Symbol('customKey'); - const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); class TestService { @Wrap(wrapFn, EXCLUSION_KEY) @@ -547,7 +579,7 @@ describe('Wrap', () => { describe('invalid decorator context', () => { it('should throw Error when applied in an unsupported context', () => { - const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); const decorator = Wrap(wrapFn); @@ -558,7 +590,7 @@ describe('Wrap', () => { }); it('should throw Error with propertyKey present but descriptor missing', () => { - const wrapFn: WrapFn = (method) => (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); const decorator = Wrap(wrapFn); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts index 97b5cb0..4b26b08 100644 --- a/tests/WrapOnClass.spec.ts +++ b/tests/WrapOnClass.spec.ts @@ -3,17 +3,17 @@ import { describe, it, expect, vi } from 'vitest'; import { WrapOnClass } from '../src/wrap-on-class'; import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { SetMeta, getMeta } from '../src/set-meta.decorator'; -import type { WrapFn, WrapContext } from '../src/hook.types'; +import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; describe('WrapOnClass', () => { describe('wraps all regular prototype methods', () => { it('should wrap every eligible method with the provided WrapFn', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -41,8 +41,8 @@ describe('WrapOnClass', () => { }); it('should preserve correct return values from wrapped methods', () => { - const wrapFn: WrapFn = (method) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -64,8 +64,8 @@ describe('WrapOnClass', () => { describe('skips constructor', () => { it('should not fire the wrapper during construction', () => { - const wrapFnSpy = vi.fn((method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFnSpy = vi.fn((_context) => { + return (invCtx, method) => method(...invCtx.args); }); @WrapOnClass(wrapFnSpy) @@ -81,13 +81,13 @@ describe('WrapOnClass', () => { } } + // WrapFn is called once at decoration time for each eligible method + expect(wrapFnSpy).toHaveBeenCalledOnce(); + const service = new TestService(); expect(service.value).toBe(42); - // WrapFn should not have been called during construction - expect(wrapFnSpy).not.toHaveBeenCalled(); - - // But should fire when calling a method + // Still called only once (decoration time, not per invocation) service.doWork(); expect(wrapFnSpy).toHaveBeenCalledOnce(); }); @@ -95,9 +95,9 @@ describe('WrapOnClass', () => { it('should not include constructor in the set of wrapped property names', () => { const wrappedNames: string[] = []; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { wrappedNames.push(String(context.propertyKey)); - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -115,12 +115,13 @@ describe('WrapOnClass', () => { } } + // wrappedNames populated at decoration time + expect(wrappedNames).toEqual(['alpha', 'beta']); + expect(wrappedNames).not.toContain('constructor'); + const service = new TestService(); service.alpha(); service.beta(); - - expect(wrappedNames).toEqual(['alpha', 'beta']); - expect(wrappedNames).not.toContain('constructor'); }); }); @@ -128,10 +129,10 @@ describe('WrapOnClass', () => { it('should not wrap getter or setter accessors', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -164,10 +165,10 @@ describe('WrapOnClass', () => { it('should skip getter-only properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -193,10 +194,10 @@ describe('WrapOnClass', () => { const calls: string[] = []; let stored = 0; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -224,10 +225,10 @@ describe('WrapOnClass', () => { it('should not attempt to wrap non-function prototype properties', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -255,10 +256,10 @@ describe('WrapOnClass', () => { it('should skip string and object prototype values', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -293,10 +294,10 @@ describe('WrapOnClass', () => { it('should skip methods explicitly excluded via SetMeta with default key', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -324,10 +325,10 @@ describe('WrapOnClass', () => { const CUSTOM_KEY = Symbol('custom'); const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -357,17 +358,17 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const classWrapFn: WrapFn = (context) => { + return (invCtx, method) => { classCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; - const methodWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const methodWrapFn: WrapFn = (context) => { + return (invCtx, method) => { methodCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -397,17 +398,17 @@ describe('WrapOnClass', () => { const classCalls: string[] = []; const methodCalls: string[] = []; - const classWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const classWrapFn: WrapFn = (context) => { + return (invCtx, method) => { classCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; - const methodWrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const methodWrapFn: WrapFn = (context) => { + return (invCtx, method) => { methodCalls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -437,10 +438,10 @@ describe('WrapOnClass', () => { it('should default to WRAP_APPLIED_KEY when no exclusionKey is provided', () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -465,8 +466,8 @@ describe('WrapOnClass', () => { }); it('should set WRAP_APPLIED_KEY metadata on methods it wraps', () => { - const wrapFn: WrapFn = (method) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -488,8 +489,8 @@ describe('WrapOnClass', () => { it('should set custom exclusion key metadata on wrapped methods', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -511,8 +512,8 @@ describe('WrapOnClass', () => { it('should not set WRAP_APPLIED_KEY when a custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -539,17 +540,17 @@ describe('WrapOnClass', () => { const callsA: string[] = []; const callsB: string[] = []; - const wrapFnA: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFnA: WrapFn = (context) => { + return (invCtx, method) => { callsA.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; - const wrapFnB: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFnB: WrapFn = (context) => { + return (invCtx, method) => { callsB.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; @@ -573,8 +574,8 @@ describe('WrapOnClass', () => { describe('this binding is preserved', () => { it('should preserve this context in wrapped methods', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -591,13 +592,17 @@ describe('WrapOnClass', () => { }); }); - describe('WrapContext is correctly populated', () => { - it('should pass correct WrapContext fields for each wrapped method', () => { - const capturedContexts: WrapContext[] = []; + describe('WrapContext and InvocationContext are correctly populated', () => { + it('should pass decoration-time fields in WrapContext and runtime fields in InvocationContext', () => { + const capturedWrapContexts: WrapContext[] = []; + let capturedInvCtx: InvocationContext | undefined; - const wrapFn: WrapFn = (method, context) => { - capturedContexts.push(context); - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (context) => { + capturedWrapContexts.push(context); + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; }; @WrapOnClass(wrapFn) @@ -607,17 +612,25 @@ describe('WrapOnClass', () => { } } + // WrapContext captured at decoration time + expect(capturedWrapContexts).toHaveLength(1); + + const wrapCtx = capturedWrapContexts[0]; + expect(wrapCtx.propertyKey).toBe('greet'); + expect(wrapCtx.parameterNames).toEqual(['name']); + expect(wrapCtx.descriptor).toBeDefined(); + // WrapContext should NOT have target or className + expect('target' in wrapCtx).toBe(false); + expect('className' in wrapCtx).toBe(false); + const service = new TestService(); service.greet('world'); - expect(capturedContexts).toHaveLength(1); - - const ctx = capturedContexts[0]; - expect(ctx.target).toBe(service); - expect(ctx.propertyKey).toBe('greet'); - expect(ctx.parameterNames).toEqual(['name']); - expect(ctx.className).toBe('TestService'); - expect(ctx.descriptor).toBeDefined(); + // InvocationContext should have runtime fields + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.target).toBe(service); + expect(capturedInvCtx!.className).toBe('TestService'); + expect(capturedInvCtx!.args).toEqual(['world']); }); }); @@ -625,10 +638,10 @@ describe('WrapOnClass', () => { it('should wrap async methods correctly', async () => { const calls: string[] = []; - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { + const wrapFn: WrapFn = (context) => { + return (invCtx, method) => { calls.push(String(context.propertyKey)); - return method(...args); + return method(...invCtx.args); }; }; diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts index 6ee6d76..ec06340 100644 --- a/tests/WrapOnMethod.spec.ts +++ b/tests/WrapOnMethod.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { WrapOnMethod, WRAP_KEY } from '../src/wrap-on-method'; import { getMeta, SetMeta } from '../src/set-meta.decorator'; -import type { WrapFn, WrapContext } from '../src/hook.types'; +import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; describe('WrapOnMethod', () => { describe('WRAP_KEY', () => { @@ -13,9 +13,9 @@ describe('WrapOnMethod', () => { }); describe('basic wrapping', () => { - it('should call wrapFn per invocation with bound method and WrapContext', () => { - const wrapFnSpy = vi.fn((method, _context) => { - return (...args: unknown[]) => method(...args); + it('should call wrapFn once at decoration time with WrapContext', () => { + const wrapFnSpy = vi.fn((_context) => { + return (_invCtx, method) => method(..._invCtx.args); }); class TestService { @@ -25,22 +25,27 @@ describe('WrapOnMethod', () => { } } - const service = new TestService(); - - expect(wrapFnSpy).not.toHaveBeenCalled(); + // wrapFn is called at decoration time, before any instance is created + expect(wrapFnSpy).toHaveBeenCalledTimes(1); + const service = new TestService(); const result = service.greet('world'); expect(result).toBe('hello world'); + // Still called only once (decoration time) expect(wrapFnSpy).toHaveBeenCalledTimes(1); }); - it('should call wrapFn on each invocation, not at decoration time', () => { + it('should call wrapFn once at decoration time, not on each invocation', () => { + let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (method, _context) => { - callCount++; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + wrapCount++; + return (invCtx, method) => { + callCount++; + return method(...invCtx.args); + }; }; class TestService { @@ -50,23 +55,60 @@ describe('WrapOnMethod', () => { } } + // wrapFn called at decoration time + expect(wrapCount).toBe(1); expect(callCount).toBe(0); const service = new TestService(); service.doWork(); + expect(wrapCount).toBe(1); expect(callCount).toBe(1); service.doWork(); + expect(wrapCount).toBe(1); expect(callCount).toBe(2); service.doWork(); + expect(wrapCount).toBe(1); expect(callCount).toBe(3); }); + it('should reuse the same factory function across instances', () => { + let wrapCount = 0; + + const wrapFn: WrapFn = (_context) => { + wrapCount++; + return (invCtx, method) => method(...invCtx.args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + doWork() { + return 42; + } + } + + // wrapFn called once at decoration time + expect(wrapCount).toBe(1); + + const serviceA = new TestService(); + const serviceB = new TestService(); + + serviceA.doWork(); + expect(wrapCount).toBe(1); + + serviceB.doWork(); + expect(wrapCount).toBe(1); + + serviceA.doWork(); + serviceB.doWork(); + expect(wrapCount).toBe(1); + }); + it('should return the result from innerFn', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => { - const result = method(...args) as number; + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + const result = method(...invCtx.args) as number; return result * 2; }; }; @@ -85,8 +127,8 @@ describe('WrapOnMethod', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -102,12 +144,14 @@ describe('WrapOnMethod', () => { expect(service.greet('world')).toBe('Hello, world'); }); - it('should pass a pre-bound method that works without explicit this', () => { + it('should pass a pre-bound method per invocation', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { - capturedMethod = method; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedMethod = method; + return method(...invCtx.args); + }; }; class TestService { @@ -127,13 +171,17 @@ describe('WrapOnMethod', () => { expect(capturedMethod).toBeDefined(); expect(capturedMethod!()).toBe('instance-data'); }); + }); - it('should provide className from this.constructor.name', () => { - let capturedContext: WrapContext | undefined; + describe('InvocationContext fields', () => { + it('should provide className and target in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; - const wrapFn: WrapFn = (method, context) => { - capturedContext = context; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; }; class MySpecialService { @@ -146,18 +194,70 @@ describe('WrapOnMethod', () => { const service = new MySpecialService(); service.doWork(); - expect(capturedContext).toBeDefined(); - expect(capturedContext!.className).toBe('MySpecialService'); + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.target).toBe(service); + expect(capturedInvCtx!.className).toBe('MySpecialService'); + }); + + it('should provide args and argsObject in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; + }; + + class TestService { + @WrapOnMethod(wrapFn) + greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + } + + const service = new TestService(); + service.greet('world', 'hi'); + + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.args).toEqual(['world', 'hi']); + expect(capturedInvCtx!.argsObject).toEqual({ name: 'world', greeting: 'hi' }); + }); + + it('should include WrapContext fields (propertyKey, parameterNames, descriptor) in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; + }; + + class TestService { + @WrapOnMethod(wrapFn) + greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + } + + const service = new TestService(); + service.greet('world', 'hi'); + + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.propertyKey).toBe('greet'); + expect(capturedInvCtx!.parameterNames).toEqual(['name', 'greeting']); + expect(capturedInvCtx!.descriptor).toBeDefined(); }); }); describe('WrapContext fields', () => { - it('should provide all expected context fields', () => { + it('should provide decoration-time context fields', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { capturedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -166,25 +266,22 @@ describe('WrapOnMethod', () => { return `${greeting} ${name}`; } } + void TestService; - const service = new TestService(); - service.greet('world', 'hi'); - + // WrapContext is captured at decoration time expect(capturedContext).toBeDefined(); - expect(capturedContext!.target).toBe(service); expect(capturedContext!.propertyKey).toBe('greet'); expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); - expect(capturedContext!.className).toBe('TestService'); expect(capturedContext!.descriptor).toBeDefined(); expect(typeof capturedContext!.descriptor.value).toBe('function'); }); - it('should NOT include args or argsObject in WrapContext', () => { + it('should NOT include target, className, args, or argsObject in WrapContext', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { capturedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -193,11 +290,12 @@ describe('WrapOnMethod', () => { return x; } } + void TestService; - const service = new TestService(); - service.doWork(42); - + // WrapContext is captured at decoration time expect(capturedContext).toBeDefined(); + expect('target' in capturedContext!).toBe(false); + expect('className' in capturedContext!).toBe(false); expect('args' in capturedContext!).toBe(false); expect('argsObject' in capturedContext!).toBe(false); }); @@ -205,11 +303,11 @@ describe('WrapOnMethod', () => { describe('parameter names extraction', () => { it('should extract parameter names at decoration time', () => { - const contexts: WrapContext[] = []; + let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { - contexts.push(context); - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (context) => { + capturedContext = context; + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -218,23 +316,44 @@ describe('WrapOnMethod', () => { return price + tax - discount; } } + void TestService; - const service = new TestService(); - service.calculate(100, 10, 5); - service.calculate(200, 20, 10); + expect(capturedContext!.parameterNames).toEqual(['price', 'tax', 'discount']); + }); - // Both calls should have the same parameterNames (extracted once at decoration time) - expect(contexts[0].parameterNames).toEqual(['price', 'tax', 'discount']); - expect(contexts[1].parameterNames).toEqual(['price', 'tax', 'discount']); - expect(contexts[0].parameterNames).toBe(contexts[1].parameterNames); // Same reference + it('should reuse the same WrapContext reference since wrapFn is called once', () => { + let capturedContext: WrapContext | undefined; + + const wrapFn: WrapFn = (context) => { + capturedContext = context; + return (invCtx, method) => method(...invCtx.args); + }; + + class TestService { + @WrapOnMethod(wrapFn) + calculate(price: number, tax: number, discount: number) { + return price + tax - discount; + } + } + + const firstCapture = capturedContext; + + const serviceA = new TestService(); + const serviceB = new TestService(); + serviceA.calculate(100, 10, 5); + serviceB.calculate(200, 20, 10); + + // WrapContext is the same reference (captured once at decoration time) + expect(capturedContext).toBe(firstCapture); + expect(capturedContext!.parameterNames).toEqual(['price', 'tax', 'discount']); }); it('should return empty array for a method with no parameters', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { capturedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -243,9 +362,7 @@ describe('WrapOnMethod', () => { return 'ok'; } } - - const service = new TestService(); - service.noParams(); + void TestService; expect(capturedContext).toBeDefined(); expect(capturedContext!.parameterNames).toEqual([]); @@ -254,8 +371,8 @@ describe('WrapOnMethod', () => { describe('exclusion key', () => { it('should set WRAP_KEY as default exclusion key', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -276,8 +393,8 @@ describe('WrapOnMethod', () => { it('should use custom exclusion key when provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -298,8 +415,8 @@ describe('WrapOnMethod', () => { it('should NOT set default WRAP_KEY when custom key is provided', () => { const CUSTOM_KEY = Symbol('custom'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -324,8 +441,8 @@ describe('WrapOnMethod', () => { it('should preserve SetMeta metadata after wrapping', () => { const META_KEY = Symbol('testMeta'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -348,8 +465,8 @@ describe('WrapOnMethod', () => { const KEY_A = Symbol('a'); const KEY_B = Symbol('b'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -373,8 +490,8 @@ describe('WrapOnMethod', () => { describe('sync method wrapping', () => { it('should pass through the return value unchanged when wrapper delegates', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -391,8 +508,8 @@ describe('WrapOnMethod', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -409,8 +526,8 @@ describe('WrapOnMethod', () => { describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -427,9 +544,9 @@ describe('WrapOnMethod', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (method, _context) => { - return async (...args: unknown[]) => { - const result = (await method(...args)) as string; + const wrapFn: WrapFn> = (_context) => { + return async (invCtx, method) => { + const result = (await method(...invCtx.args)) as string; return `modified: ${result}`; }; }; @@ -450,8 +567,8 @@ describe('WrapOnMethod', () => { it('should propagate async errors (rejected promises) from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { @@ -468,8 +585,8 @@ describe('WrapOnMethod', () => { describe('method decorator return type', () => { it('should return a valid MethodDecorator', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; const decorator = WrapOnMethod(wrapFn); @@ -477,8 +594,8 @@ describe('WrapOnMethod', () => { }); it('should replace descriptor.value with the wrapped function', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; class TestService { diff --git a/tests/hook-types.spec.ts b/tests/hook-types.spec.ts index d8eac01..7c899be 100644 --- a/tests/hook-types.spec.ts +++ b/tests/hook-types.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { WrapContext, + InvocationContext, WrapFn, HookContext, HookArgs, @@ -18,81 +19,128 @@ import type { } from '../src/hook.types'; describe('hook.types', () => { - describe('WrapContext', () => { - it('contains exactly the 5 required fields', () => { + describe('WrapContext (decoration-time)', () => { + it('contains exactly the 3 decoration-time fields', () => { const ctx: WrapContext = { - target: {}, propertyKey: 'method', parameterNames: ['a', 'b'], - className: 'TestClass', descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, }; - expect(ctx.target).toBeDefined(); expect(ctx.propertyKey).toBe('method'); expect(ctx.parameterNames).toEqual(['a', 'b']); - expect(ctx.className).toBe('TestClass'); expect(ctx.descriptor).toBeDefined(); }); - it('does not contain args or argsObject', () => { + it('does not contain target, className, args, or argsObject', () => { const ctx: WrapContext = { - target: {}, propertyKey: 'method', parameterNames: [], - className: 'TestClass', descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, }; + expect(ctx).not.toHaveProperty('target'); + expect(ctx).not.toHaveProperty('className'); expect(ctx).not.toHaveProperty('args'); expect(ctx).not.toHaveProperty('argsObject'); }); }); + describe('InvocationContext (per-call, extends WrapContext)', () => { + it('contains target, className, args, argsObject, plus WrapContext fields', () => { + const invCtx: InvocationContext = { + propertyKey: 'method', + parameterNames: ['a', 'b'], + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'TestClass', + args: [1, 2], + argsObject: { a: 1, b: 2 }, + }; + + expect(invCtx.target).toBeDefined(); + expect(invCtx.className).toBe('TestClass'); + expect(invCtx.args).toEqual([1, 2]); + expect(invCtx.argsObject).toEqual({ a: 1, b: 2 }); + expect(invCtx.propertyKey).toBe('method'); + expect(invCtx.parameterNames).toEqual(['a', 'b']); + expect(invCtx.descriptor).toBeDefined(); + }); + + it('is assignable to WrapContext (structural subtype)', () => { + const invCtx: InvocationContext = { + propertyKey: 'method', + parameterNames: [], + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + target: {}, + className: 'Test', + args: [], + argsObject: undefined, + }; + + const wrapCtx: WrapContext = invCtx; + expect(wrapCtx.propertyKey).toBe('method'); + }); + }); + describe('WrapFn', () => { - it('accepts a method and WrapContext, returns a function', () => { - const wrapFn: WrapFn = (method, context) => { - return (...args: unknown[]) => { - return method(...args); + it('accepts WrapContext and returns a function taking InvocationContext and method', () => { + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + return method(...invCtx.args); }; }; const fakeMethod = (...args: unknown[]) => args[0]; const ctx: WrapContext = { - target: {}, propertyKey: 'test', parameterNames: [], - className: 'Test', descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, }; - const wrapped = wrapFn(fakeMethod, ctx); - expect(wrapped(42)).toBe(42); + const factory = wrapFn(ctx); + const invCtx: InvocationContext = { + propertyKey: 'test', + parameterNames: [], + descriptor: ctx.descriptor, + target: {}, + className: 'Test', + args: [42], + argsObject: undefined, + }; + expect(factory(invCtx, fakeMethod)).toBe(42); }); it('supports generic return type parameter', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => { - return (method(...args) as number) * 2; + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + return (method(...invCtx.args) as number) * 2; }; }; const fakeMethod = (..._args: unknown[]) => 21; const ctx: WrapContext = { - target: {}, propertyKey: 'test', parameterNames: [], - className: 'Test', descriptor: { value: fakeMethod, writable: true, enumerable: true, configurable: true }, }; - const wrapped = wrapFn(fakeMethod, ctx); - expect(wrapped()).toBe(42); + const factory = wrapFn(ctx); + const invCtx: InvocationContext = { + propertyKey: 'test', + parameterNames: [], + descriptor: ctx.descriptor, + target: {}, + className: 'Test', + args: [], + argsObject: undefined, + }; + expect(factory(invCtx, fakeMethod)).toBe(42); }); }); - describe('HookContext extends WrapContext', () => { - it('contains all 7 fields (5 from WrapContext + args + argsObject)', () => { + describe('HookContext extends InvocationContext', () => { + it('contains all 7 fields (3 from WrapContext + 4 runtime)', () => { const hookCtx: HookContext = { target: {}, propertyKey: 'method', @@ -126,6 +174,22 @@ describe('hook.types', () => { const wrapCtx: WrapContext = hookCtx; expect(wrapCtx.propertyKey).toBe('method'); }); + + it('is assignable to InvocationContext (structural subtype)', () => { + const hookCtx: HookContext = { + target: {}, + propertyKey: 'method', + parameterNames: [], + className: 'Cls', + descriptor: { value: () => undefined, writable: true, enumerable: true, configurable: true }, + args: [], + argsObject: undefined, + }; + + const invCtx: InvocationContext = hookCtx; + expect(invCtx.target).toBeDefined(); + expect(invCtx.className).toBe('Cls'); + }); }); describe('existing type exports remain unchanged', () => { diff --git a/tests/wrapFunction.spec.ts b/tests/wrapFunction.spec.ts index 37e9217..3354cc7 100644 --- a/tests/wrapFunction.spec.ts +++ b/tests/wrapFunction.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { wrapMethod } from '../src/wrap-on-method'; -import type { WrapFn, WrapContext } from '../src/hook.types'; +import type { WrapFn, WrapContext, InvocationContext } from '../src/hook.types'; /** * Helper that simulates how {@link WrapOnMethod} extracts the original @@ -13,9 +13,9 @@ const asMethod = (fn: Function): ((...args: unknown[]) => unknown) => describe('wrapFunction', () => { describe('basic wrapping', () => { - it('should return a function that invokes wrapFn per call', () => { - const wrapFnSpy = vi.fn((method, _context) => { - return (...args: unknown[]) => method(...args); + it('should call wrapFn once at wrap time', () => { + const wrapFnSpy = vi.fn((_context) => { + return (invCtx, method) => method(...invCtx.args); }); function greet(name: string) { @@ -31,21 +31,27 @@ describe('wrapFunction', () => { descriptor, }); - expect(wrapFnSpy).not.toHaveBeenCalled(); + // wrapFn is called immediately at wrap time + expect(wrapFnSpy).toHaveBeenCalledTimes(1); const instance = { constructor: { name: 'TestService' } }; const result = wrapped.call(instance, 'world'); expect(result).toBe('hello world'); + // Still called only once (wrap time) expect(wrapFnSpy).toHaveBeenCalledTimes(1); }); - it('should invoke wrapFn on every call, not just the first', () => { + it('should invoke the inner function on each call, reusing the factory result', () => { + let wrapCount = 0; let callCount = 0; - const wrapFn: WrapFn = (method, _context) => { - callCount++; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + wrapCount++; + return (invCtx, method) => { + callCount++; + return method(...invCtx.args); + }; }; function doWork() { @@ -60,24 +66,66 @@ describe('wrapFunction', () => { descriptor, }); - const instance = { constructor: { name: 'TestService' } }; - + // wrapFn called at wrap time + expect(wrapCount).toBe(1); expect(callCount).toBe(0); + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance); + expect(wrapCount).toBe(1); expect(callCount).toBe(1); wrapped.call(instance); + expect(wrapCount).toBe(1); expect(callCount).toBe(2); wrapped.call(instance); + expect(wrapCount).toBe(1); expect(callCount).toBe(3); }); + it('should reuse the factory result for different instances', () => { + let wrapCount = 0; + + const wrapFn: WrapFn = (_context) => { + wrapCount++; + return (invCtx, method) => method(...invCtx.args); + }; + + function doWork() { + return 42; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'doWork', + descriptor, + }); + + // wrapFn called once at wrap time + expect(wrapCount).toBe(1); + + const instanceA = { constructor: { name: 'TestService' } }; + const instanceB = { constructor: { name: 'TestService' } }; + + wrapped.call(instanceA); + expect(wrapCount).toBe(1); + + wrapped.call(instanceB); + expect(wrapCount).toBe(1); + + wrapped.call(instanceA); + wrapped.call(instanceB); + expect(wrapCount).toBe(1); + }); + it('should return the result from the inner function', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => { - const result = method(...args) as number; + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + const result = method(...invCtx.args) as number; return result * 2; }; }; @@ -101,8 +149,8 @@ describe('wrapFunction', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; const original: (...args: unknown[]) => unknown = function ( @@ -123,12 +171,14 @@ describe('wrapFunction', () => { expect(wrapped.call(instance, 'world')).toBe('Hello, world'); }); - it('should pass a pre-bound method that works without explicit this', () => { + it('should pass a pre-bound method per invocation', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; - const wrapFn: WrapFn = (method, _context) => { - capturedMethod = method; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedMethod = method; + return method(...invCtx.args); + }; }; const original: (...args: unknown[]) => unknown = function ( @@ -153,13 +203,13 @@ describe('wrapFunction', () => { }); }); - describe('WrapContext fields', () => { - it('should provide all expected context fields', () => { + describe('WrapContext fields (decoration-time)', () => { + it('should provide decoration-time context fields', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { capturedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); }; function greet(name: string, greeting: string) { @@ -168,29 +218,57 @@ describe('wrapFunction', () => { const original = asMethod(greet); const descriptor: PropertyDescriptor = { value: original, writable: true }; - const wrapped = wrapMethod(original, wrapFn, { + wrapMethod(original, wrapFn, { parameterNames: ['name', 'greeting'], propertyKey: 'greet', descriptor, }); - const instance = { constructor: { name: 'TestService' } }; - wrapped.call(instance, 'world', 'hi'); - + // WrapContext captured at wrap time expect(capturedContext).toBeDefined(); - expect(capturedContext!.target).toBe(instance); expect(capturedContext!.propertyKey).toBe('greet'); expect(capturedContext!.parameterNames).toEqual(['name', 'greeting']); - expect(capturedContext!.className).toBe('TestService'); expect(capturedContext!.descriptor).toBe(descriptor); }); - it('should provide className from this.constructor.name', () => { + it('should NOT include target, className, args, or argsObject in WrapContext', () => { let capturedContext: WrapContext | undefined; - const wrapFn: WrapFn = (method, context) => { + const wrapFn: WrapFn = (context) => { capturedContext = context; - return (...args: unknown[]) => method(...args); + return (invCtx, method) => method(...invCtx.args); + }; + + function doWork(x: number) { + return x; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + wrapMethod(original, wrapFn, { + parameterNames: ['x'], + propertyKey: 'doWork', + descriptor, + }); + + // WrapContext captured at wrap time + expect(capturedContext).toBeDefined(); + expect('target' in capturedContext!).toBe(false); + expect('className' in capturedContext!).toBe(false); + expect('args' in capturedContext!).toBe(false); + expect('argsObject' in capturedContext!).toBe(false); + }); + }); + + describe('InvocationContext fields (per-call)', () => { + it('should provide target and className in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; }; function doWork() { @@ -208,44 +286,80 @@ describe('wrapFunction', () => { const instance = { constructor: { name: 'MySpecialService' } }; wrapped.call(instance); - expect(capturedContext).toBeDefined(); - expect(capturedContext!.className).toBe('MySpecialService'); + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.target).toBe(instance); + expect(capturedInvCtx!.className).toBe('MySpecialService'); }); - it('should NOT include args or argsObject in WrapContext', () => { - let capturedContext: WrapContext | undefined; + it('should provide args and argsObject in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; - const wrapFn: WrapFn = (method, context) => { - capturedContext = context; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; }; - function doWork(x: number) { - return x; + function greet(name: string, greeting: string) { + return `${greeting} ${name}`; + } + + const original = asMethod(greet); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: ['name', 'greeting'], + propertyKey: 'greet', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance, 'world', 'hi'); + + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.args).toEqual(['world', 'hi']); + expect(capturedInvCtx!.argsObject).toEqual({ name: 'world', greeting: 'hi' }); + }); + + it('should include WrapContext fields in InvocationContext', () => { + let capturedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; + }; + + function doWork() { + return 'done'; } const original = asMethod(doWork); const descriptor: PropertyDescriptor = { value: original, writable: true }; const wrapped = wrapMethod(original, wrapFn, { - parameterNames: ['x'], + parameterNames: [], propertyKey: 'doWork', descriptor, }); const instance = { constructor: { name: 'TestService' } }; - wrapped.call(instance, 42); + wrapped.call(instance); - expect(capturedContext).toBeDefined(); - expect('args' in capturedContext!).toBe(false); - expect('argsObject' in capturedContext!).toBe(false); + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.propertyKey).toBe('doWork'); + expect(capturedInvCtx!.parameterNames).toEqual([]); + expect(capturedInvCtx!.descriptor).toBe(descriptor); }); it('should return empty string for className when constructor has no name', () => { - let capturedContext: WrapContext | undefined; + let capturedInvCtx: InvocationContext | undefined; - const wrapFn: WrapFn = (method, context) => { - capturedContext = context; - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; }; function doWork() { @@ -264,18 +378,49 @@ describe('wrapFunction', () => { const instance = { constructor: {} }; wrapped.call(instance as object); - expect(capturedContext).toBeDefined(); - expect(capturedContext!.className).toBe(''); + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.className).toBe(''); + }); + + it('should return undefined argsObject for method with no parameters', () => { + let capturedInvCtx: InvocationContext | undefined; + + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvCtx = invCtx; + return method(...invCtx.args); + }; + }; + + function doWork() { + return 'done'; + } + + const original = asMethod(doWork); + const descriptor: PropertyDescriptor = { value: original, writable: true }; + const wrapped = wrapMethod(original, wrapFn, { + parameterNames: [], + propertyKey: 'doWork', + descriptor, + }); + + const instance = { constructor: { name: 'TestService' } }; + wrapped.call(instance); + + expect(capturedInvCtx).toBeDefined(); + expect(capturedInvCtx!.argsObject).toBeUndefined(); }); }); describe('parameter names reuse', () => { it('should reuse the same parameterNames reference across calls', () => { - const contexts: WrapContext[] = []; + const capturedInvContexts: InvocationContext[] = []; - const wrapFn: WrapFn = (method, context) => { - contexts.push(context); - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => { + capturedInvContexts.push(invCtx); + return method(...invCtx.args); + }; }; function calculate(price: number, tax: number) { @@ -291,21 +436,22 @@ describe('wrapFunction', () => { descriptor, }); - const instance = { constructor: { name: 'TestService' } }; - wrapped.call(instance, 100, 10); - wrapped.call(instance, 200, 20); + const instanceA = { constructor: { name: 'TestService' } }; + const instanceB = { constructor: { name: 'TestService' } }; + wrapped.call(instanceA, 100, 10); + wrapped.call(instanceB, 200, 20); - expect(contexts[0].parameterNames).toEqual(['price', 'tax']); - expect(contexts[1].parameterNames).toEqual(['price', 'tax']); - // Same reference passed each time (extracted once, reused) - expect(contexts[0].parameterNames).toBe(contexts[1].parameterNames); + expect(capturedInvContexts[0].parameterNames).toEqual(['price', 'tax']); + expect(capturedInvContexts[1].parameterNames).toEqual(['price', 'tax']); + // Same reference passed each time (from decoration-time context spread) + expect(capturedInvContexts[0].parameterNames).toBe(capturedInvContexts[1].parameterNames); }); }); describe('async methods', () => { it('should work with async methods', async () => { - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; async function fetchData(id: number): Promise { @@ -327,9 +473,9 @@ describe('wrapFunction', () => { }); it('should allow async wrapper to modify async results', async () => { - const wrapFn: WrapFn> = (method, _context) => { - return async (...args: unknown[]) => { - const result = (await method(...args)) as string; + const wrapFn: WrapFn> = (_context) => { + return async (invCtx, method) => { + const result = (await method(...invCtx.args)) as string; return `modified: ${result}`; }; }; @@ -355,8 +501,8 @@ describe('wrapFunction', () => { it('should propagate async errors from the original method', async () => { const asyncError = new Error('async failure'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; async function failingAsync() { @@ -380,8 +526,8 @@ describe('wrapFunction', () => { it('should propagate sync errors from the original method', () => { const syncError = new Error('sync failure'); - const wrapFn: WrapFn = (method, _context) => { - return (...args: unknown[]) => method(...args); + const wrapFn: WrapFn = (_context) => { + return (invCtx, method) => method(...invCtx.args); }; function failing(): never { @@ -406,5 +552,10 @@ describe('wrapFunction', () => { const indexModule = await import('../src/index'); expect(typeof indexModule.wrapFunction).toBe('function'); }); + + it('should export buildArgsObject from the main index', async () => { + const indexModule = await import('../src/index'); + expect(typeof indexModule.buildArgsObject).toBe('function'); + }); }); }); From 7b9f146ec8365b63d10cc4bf88b4a6da3178952f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 23:55:40 +0200 Subject: [PATCH 09/10] docs: remove unnecesary changes --- .claude/skills/.gitkeep | 0 .claude/skills/wrap-decorator/SKILL.md | 273 ------------------------- README.md | 3 +- 3 files changed, 1 insertion(+), 275 deletions(-) delete mode 100644 .claude/skills/.gitkeep delete mode 100644 .claude/skills/wrap-decorator/SKILL.md diff --git a/.claude/skills/.gitkeep b/.claude/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.claude/skills/wrap-decorator/SKILL.md b/.claude/skills/wrap-decorator/SKILL.md deleted file mode 100644 index c17215d..0000000 --- a/.claude/skills/wrap-decorator/SKILL.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -name: Wrap Decorator -description: Design and implementation guide for the Wrap decorator primitive and its relationship to the Effect decorator in the base-decorators library -topics: typescript, decorators, method-wrapping, higher-order-functions, refactoring -created: 2026-04-08 -updated: 2026-04-08 -scratchpad: .specs/scratchpad/690c5e80.md ---- - -# Wrap Decorator - -## Overview - -`Wrap` is a lower-level decorator primitive that exposes raw method wrapping via a higher-order function. Unlike `Effect` (which provides lifecycle hooks), `Wrap` gives the user full control over execution flow by accepting a factory that returns a replacement function. `Effect` is rebuilt on top of `Wrap` by implementing the hooks lifecycle inside the factory. - ---- - -## Key Concepts - -- **WrapContext**: Decoration-time context plus runtime context (target, className) -- everything in `HookContext` except `args` and `argsObject`. -- **WrapFn**: The factory signature `(method, context: WrapContext) => (...args) => unknown`. Called per invocation with a `this`-bound original method. -- **HookContext extends WrapContext**: Interface extension -- `HookContext` adds `args` and `argsObject`. Zero new type assertions needed. -- **WrapOnMethod**: Low-level method decorator that calls the WrapFn factory per invocation. -- **WrapOnClass**: Class decorator that applies WrapOnMethod to all eligible prototype methods. -- **Wrap**: Unified decorator (like `Effect`) that dispatches to `WrapOnClass` or `WrapOnMethod` based on argument count. -- **WRAP_APPLIED_KEY**: Replaces `EFFECT_APPLIED_KEY` as the default sentinel symbol for double-wrap prevention. - ---- - -## Documentation & References - -| Resource | Description | Link | -|----------|-------------|------| -| TypeScript Decorators (legacy) | experimentalDecorators API reference | https://www.typescriptlang.org/docs/handbook/decorators.html | -| base-decorators source | Current implementation to refactor | /workspaces/base-decorators/src/ | -| base-decorators tests | Tests to update/rename | /workspaces/base-decorators/tests/ | -| Type safety rule | Must not increase as-cast count | /workspaces/base-decorators/.claude/rules/preserve-type-safety-during-refactoring.md | - ---- - -## Recommended File Structure After Refactoring - -| Old File | New File | Role | -|----------|----------|------| -| `src/effect-on-method.ts` | `src/wrap-on-method.ts` | Low-level method wrapper (WRAP_APPLIED_KEY, WrapOnMethod, copySymMeta) | -| `src/effect-on-class.ts` | `src/wrap-on-class.ts` | Class decorator using WrapOnMethod | -| `src/effect.decorator.ts` | `src/wrap.decorator.ts` + `src/effect.decorator.ts` | Wrap = primitive; Effect = hooks layer on top | -| `src/hook.types.ts` | `src/hook.types.ts` | Add WrapContext, WrapFn; HookContext extends WrapContext | -| `tests/EffectOnMethod.spec.ts` | `tests/WrapOnMethod.spec.ts` | Renamed tests | -| `tests/EffectOnClass.spec.ts` | `tests/WrapOnClass.spec.ts` | Renamed tests | -| `tests/effect-on-method-base.spec.ts` | `tests/wrap-on-method-base.spec.ts` | Internal helpers moved | - ---- - -## Patterns & Best Practices - -### Pattern 1: WrapContext as base interface - -**When to use**: Always -- this is the foundational type split. -**Trade-offs**: Clean interface extension, no extra type assertions. - -```typescript -export interface WrapContext { - target: object; - propertyKey: string | symbol; - parameterNames: string[]; - className: string; - descriptor: PropertyDescriptor; -} - -export interface HookContext extends WrapContext { - args: unknown[]; - argsObject: HookArgs; -} -``` - -### Pattern 2: Per-invocation factory with bound method - -**When to use**: WrapOnMethod calls the WrapFn factory on each method invocation. -**Trade-offs**: Slight overhead per call; required for runtime `this`/`className` access. - -```typescript -// Inside WrapOnMethod's wrapped function: -const wrapped = function(this: object, ...args: unknown[]) { - const className = (this.constructor as { name: string }).name ?? ''; - const context: WrapContext = { target: this, propertyKey, parameterNames, className, descriptor }; - const boundMethod = originalMethod.bind(this); - const wrappedFn = wrapFn(boundMethod, context); - return wrappedFn(...args); -}; -``` - -### Pattern 3: Effect as Wrap consumer - -**When to use**: Effect delegates entirely to Wrap; hooks logic moves to effect.decorator.ts. -**Trade-offs**: Effect no longer imports from effect-on-method; hooks internals live in effect.decorator.ts. - -```typescript -// In effect.decorator.ts -- Effect's internal wrap factory: -const effectWrapFn = (hooksOrFactory: HooksOrFactory) => - (boundMethod: (...args: unknown[]) => unknown, wrapCtx: WrapContext) => - (...args: unknown[]): unknown => { - const argsObject = buildArgsObject(wrapCtx.parameterNames, args); - const context: HookContext = { ...wrapCtx, args, argsObject }; - const hooks = resolveHooks(hooksOrFactory, context); - const executeMethod = attachHooks(boundMethod, args, context, hooks); - if (hooks.onInvoke) { - const invokeResult = hooks.onInvoke(context); - if (invokeResult instanceof Promise) return invokeResult.then(executeMethod); - } - return executeMethod(); - }; -``` - -### Pattern 4: Exclusion key propagation - -**When to use**: Always -- WrapOnMethod sets the exclusion key; WrapOnClass checks it. -**Trade-offs**: Consistent with existing Effect behavior; WRAP_APPLIED_KEY is the new default. - -```typescript -// WrapOnMethod sets exclusion key at decoration time -setMeta(exclusionKey, true, descriptor); - -// WrapOnClass skips methods already marked -if (getMeta(exclusionKey, descriptor) === true) continue; -``` - ---- - -## User-Facing API - -### Wrap decorator basic usage - -```typescript -import { Wrap } from 'base-decorators'; -import type { WrapContext } from 'base-decorators'; - -export const Log = () => Wrap((method, context: WrapContext) => { - console.log('method called is', context.propertyKey); - return (...args: unknown[]) => { - console.log('method called with', args); - const result = method(...args); - console.log('method returned', result); - return result; - }; -}); - -class Calculator { - @Log() - add(a: number, b: number) { - return a + b; - } -} -``` - -### Async Wrap usage - -```typescript -export const AsyncTimer = () => Wrap((method, context: WrapContext) => { - return async (...args: unknown[]) => { - const start = Date.now(); - const result = await method(...args); - console.log(`${String(context.propertyKey)} took ${Date.now() - start}ms`); - return result; - }; -}); -``` - -### Effect continues to work unchanged - -```typescript -import { Effect } from 'base-decorators'; - -class Service { - @Effect({ - onInvoke: ({ args }) => console.log('called with', args), - onReturn: ({ result }) => { console.log('result:', result); return result; }, - }) - compute(x: number) { return x * 2; } -} -``` - ---- - -## Internal Helper Migration - -These functions move FROM `effect-on-method.ts` TO `effect.decorator.ts`: - -| Function | Current Location | New Location | -|----------|-----------------|--------------| -| `buildArgsObject` | `effect-on-method.ts` | `effect.decorator.ts` | -| `attachHooks` | `effect-on-method.ts` | `effect.decorator.ts` | -| `resolveHooks` | `effect-on-method.ts` (private) | `effect.decorator.ts` (private) | -| `chainAsyncHooks` | `effect-on-method.ts` (private) | `effect.decorator.ts` (private) | -| `wrapFunction` | `effect-on-method.ts` | Eliminated -- superseded by WrapOnMethod + effectWrapFn | -| `copySymMeta` | `effect-on-method.ts` (private) | `wrap-on-method.ts` (private) | - -Note: `attachHooks` signature changes -- the `thisArg` parameter is removed since `Wrap` passes a pre-bound method. - ---- - -## Common Pitfalls & Solutions - -| Issue | Impact | Solution | -|-------|--------|----------| -| WrapFn factory called at decoration time instead of per invocation | High | Factory must be inside the `wrapped = function(this)` closure | -| method arg is unbound causing lost this context | Med | Pre-bind method to `this` before passing to factory | -| `as` type assertion count increases during refactoring | Med | Use interface extension (`HookContext extends WrapContext`) not structural casting | -| Tests importing from old file paths break | High | Update all import paths in test files | -| copySymMeta lost during refactoring | High | Keep in wrap-on-method.ts and call it from WrapOnMethod | -| attachHooks previously received thisArg separately | Med | Remove thisArg param; receive pre-bound method from WrapOnMethod | -| effect-on-method-base.spec.ts imports wrapFunction/attachHooks | High | Update test to import from new location or test via Effect | - ---- - -## Export Checklist for index.ts - -After refactoring, `src/index.ts` should export: - -```typescript -// Wrap primitive (new) -export * from './wrap-on-method'; // WrapOnMethod, WRAP_APPLIED_KEY -export * from './wrap-on-class'; // WrapOnClass -export * from './wrap.decorator'; // Wrap - -// Effect (hooks layer on top of Wrap) -export * from './effect.decorator'; // Effect, buildArgsObject (if still public) - -// Types -export type * from './hook.types'; // WrapContext, WrapFn, HookContext, etc. - -// Meta utilities -export * from './set-meta.decorator'; - -// Convenience hook decorators -export * from './on-invoke.hook'; -export * from './on-return.hook'; -export * from './on-error.hook'; -export * from './finally.hook'; -``` - ---- - -## Type Safety Checklist - -Before finalizing implementation, count `as` assertions in refactored files vs. originals: - -- `effect-on-method.ts` baseline: approximately 6-7 assertions (descriptor.value cast, copySymMeta casts) -- These assertions move to `wrap-on-method.ts` (copySymMeta, descriptor.value cast) -- `effect.decorator.ts` gains internal hook logic but HookContext extends WrapContext avoids new casts -- Final count must be equal or fewer than baseline - ---- - -## Sources & Verification - -| Source | Type | Last Verified | -|--------|------|---------------| -| `/workspaces/base-decorators/src/effect-on-method.ts` | Primary (project source) | 2026-04-08 | -| `/workspaces/base-decorators/src/effect-on-class.ts` | Primary (project source) | 2026-04-08 | -| `/workspaces/base-decorators/src/effect.decorator.ts` | Primary (project source) | 2026-04-08 | -| `/workspaces/base-decorators/src/hook.types.ts` | Primary (project source) | 2026-04-08 | -| `/workspaces/base-decorators/tests/*.spec.ts` | Primary (project tests) | 2026-04-08 | -| `.claude/rules/preserve-type-safety-during-refactoring.md` | Project rules | 2026-04-08 | -| Task file: `.specs/tasks/draft/add-wrap-decorator.feature.md` | Task definition | 2026-04-08 | - ---- - -## Changelog - -| Date | Changes | -|------|---------| -| 2026-04-08 | Initial creation for task: Add wrap decorator | diff --git a/README.md b/README.md index 0aa17b0..982a7ed 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ Basic decorator primitives for TypeScript. Writing decorators in TS is hard, thi [Usage](#usage) β€’ [Options](#options) β€’ [API Reference](#api-reference) β€’ -[Advanced Example](#advanced-example) β€’ -[Wrap Decorator](#wrap-decorator) +[Advanced Example](#advanced-example) From 51d84ad317bb628b91b2609749cbe0dd9eacf6ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 00:07:21 +0200 Subject: [PATCH 10/10] fix: swap methods and arguments order --- README.md | 10 +++---- src/effect.decorator.ts | 2 +- src/hook.types.ts | 6 ++--- src/wrap-on-class.ts | 2 +- src/wrap-on-method.ts | 12 ++++----- src/wrap.decorator.ts | 8 +++--- tests/Wrap.spec.ts | 54 +++++++++++++++++++------------------- tests/WrapOnClass.spec.ts | 48 ++++++++++++++++----------------- tests/WrapOnMethod.spec.ts | 52 ++++++++++++++++++------------------ tests/hook-types.spec.ts | 10 +++---- tests/wrapFunction.spec.ts | 36 ++++++++++++------------- 11 files changed, 120 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 982a7ed..b3540f5 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ const Log = () => Wrap((context: WrapContext) => { // Outer function: called once at decoration time console.log('decorating', context.propertyKey); - return ({args}, method) => { + return (method, {args}) => { // Inner function: called on every invocation console.log('called with', args); @@ -114,7 +114,7 @@ calc.add(2, 3); ## How It Works -**`Wrap`** accepts a factory function that receives a `WrapContext` at decoration time and returns an inner function. The inner function is called on every invocation with an `InvocationContext` and the `this`-bound original method. You control the entire execution flow: +**`Wrap`** accepts a factory function that receives a `WrapContext` at decoration time and returns an inner function. The inner function is called on every invocation with the `this`-bound original method and an `InvocationContext`. You control the entire execution flow: ```typescript import { Wrap } from 'base-decorators'; @@ -122,7 +122,7 @@ import type { WrapContext, InvocationContext } from 'base-decorators'; const Log = () => Wrap((context: WrapContext) => { // Outer: called once at decoration time. WrapContext has propertyKey, parameterNames, descriptor. - return ({ args, className }: InvocationContext, method) => { + return (method, { args, className }: InvocationContext) => { // Inner: called on every invocation. InvocationContext extends WrapContext with target, className, args, argsObject. console.log(`${className}.${String(context.propertyKey)} called`); return method(...args); @@ -219,7 +219,7 @@ import { Wrap } from 'base-decorators'; import type { WrapContext, InvocationContext } from 'base-decorators'; const AsyncTimer = () => Wrap((context: WrapContext) => { - return async ({ args }, method) => { + return async (method, { args }) => { const start = Date.now(); const result = await method(...args); @@ -519,7 +519,7 @@ The factory is called **once at decoration time** with the `WrapContext` contain | `FinallyHook` | Decorator | Convenience hook for `finally` | | `WrapContext` | Type | Decoration-time context passed to `Wrap` outer factory (propertyKey, parameterNames, descriptor) | | `InvocationContext` | Type | Per-call context extending `WrapContext` with runtime fields (target, className, args, argsObject) | -| `WrapFn` | Type | Wrapper function signature: `(context: WrapContext) => (context: InvocationContext, method) => R` | +| `WrapFn` | Type | Wrapper function signature: `(context: WrapContext) => (method, context: InvocationContext) => R` | | `HookContext` | Type | Context passed to `Effect` hooks -- equivalent to `InvocationContext` with all fields | | `EffectHooks` | Type | Lifecycle hooks object for `Effect` (onInvoke, onReturn, onError, finally) | diff --git a/src/effect.decorator.ts b/src/effect.decorator.ts index 073bcc4..5d0d1e5 100644 --- a/src/effect.decorator.ts +++ b/src/effect.decorator.ts @@ -61,8 +61,8 @@ export const Effect = ( const resolvedHooks = resolveHooks(hooks, wrapContext); return ( - invocationContext: InvocationContext, boundMethod: (...args: unknown[]) => unknown, + invocationContext: InvocationContext, ): unknown => { const hookContext: HookContext = { ...invocationContext }; diff --git a/src/hook.types.ts b/src/hook.types.ts index 922bcf7..c953039 100644 --- a/src/hook.types.ts +++ b/src/hook.types.ts @@ -39,14 +39,14 @@ export interface InvocationContext extends WrapContext { * Factory function accepted by the Wrap decorator. * * Called **once at decoration time** with a {@link WrapContext}. Returns an - * inner function that is called on every invocation with an - * {@link InvocationContext} and the `this`-bound original method. + * inner function that is called on every invocation with the `this`-bound + * original method and an {@link InvocationContext}. * * @typeParam R - The return type produced by the inner function */ export type WrapFn = ( context: WrapContext, -) => (context: InvocationContext, method: (...args: unknown[]) => unknown) => R; +) => (method: (...args: unknown[]) => unknown, context: InvocationContext) => R; /** * Shared context passed to every lifecycle hook. diff --git a/src/wrap-on-class.ts b/src/wrap-on-class.ts index a7b553a..0092bb6 100644 --- a/src/wrap-on-class.ts +++ b/src/wrap-on-class.ts @@ -37,7 +37,7 @@ import { WrapOnMethod, WRAP_KEY } from './wrap-on-method'; * ```ts * const LOG_KEY = Symbol('log'); * - * \@WrapOnClass((ctx) => (invCtx, method) => { + * \@WrapOnClass((ctx) => (method, invCtx) => { * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); * return method(...invCtx.args); * }, LOG_KEY) diff --git a/src/wrap-on-method.ts b/src/wrap-on-method.ts index c3becb4..a295ef7 100644 --- a/src/wrap-on-method.ts +++ b/src/wrap-on-method.ts @@ -24,8 +24,8 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * @typeParam R - The return type of the decorated method * @param wrapFn - Factory called once at decoration time with a * {@link WrapContext}. Returns the inner function that - * receives an {@link InvocationContext} and the - * `this`-bound original method on each call. + * receives the `this`-bound original method and an + * {@link InvocationContext} on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * {@link WRAP_KEY}. This allows different @@ -37,7 +37,7 @@ export const WRAP_KEY: unique symbol = Symbol('wrap'); * @example * ```ts * class Service { - * \@WrapOnMethod((ctx) => (invCtx, method) => { + * \@WrapOnMethod((ctx) => (method, invCtx) => { * console.log(`${String(ctx.propertyKey)} called with`, invCtx.args); * return method(...invCtx.args); * }) @@ -153,8 +153,8 @@ export const buildArgsObject = ( * @param originalMethod - The function to wrap * @param wrapFn - Factory called once at wrap time with a * {@link WrapContext}. Returns the inner function - * that receives an {@link InvocationContext} and - * the `this`-bound method on each call. + * that receives the `this`-bound method and an + * {@link InvocationContext} on each call. * @param options - Decoration-time metadata for the method being wrapped * @returns A function that, when called, binds the original method, builds * an {@link InvocationContext}, and delegates to the inner function @@ -199,7 +199,7 @@ export const wrapMethod = ( argsObject, }; - return factoryFn(invocationContext, boundMethod); + return factoryFn(boundMethod, invocationContext); }; }; diff --git a/src/wrap.decorator.ts b/src/wrap.decorator.ts index 5f686f8..dea1340 100644 --- a/src/wrap.decorator.ts +++ b/src/wrap.decorator.ts @@ -22,8 +22,8 @@ import type { WrapFn } from './hook.types'; * @typeParam R - The return type expected from the wrapper function * @param wrapFn - Factory called once at decoration time with a * {@link WrapContext}. Returns the inner function that - * receives an {@link InvocationContext} and the - * `this`-bound original method on each call. + * receives the `this`-bound original method and an + * {@link InvocationContext} on each call. * @param exclusionKey - Optional symbol used to mark the wrapped method. When * provided, this key is set instead of the default * `WRAP_APPLIED_KEY`. This allows different @@ -36,7 +36,7 @@ import type { WrapFn } from './hook.types'; * ```ts * // Method-level usage * class Service { - * \@Wrap((ctx) => (invCtx, method) => { + * \@Wrap((ctx) => (method, invCtx) => { * console.log(`${invCtx.className}.${String(ctx.propertyKey)} called`); * return method(...invCtx.args); * }) @@ -44,7 +44,7 @@ import type { WrapFn } from './hook.types'; * } * * // Class-level usage - * \@Wrap((ctx) => (invCtx, method) => { + * \@Wrap((ctx) => (method, invCtx) => { * console.log(`${String(ctx.propertyKey)} called`); * return method(...invCtx.args); * }) diff --git a/tests/Wrap.spec.ts b/tests/Wrap.spec.ts index 9834d2c..a4313c4 100644 --- a/tests/Wrap.spec.ts +++ b/tests/Wrap.spec.ts @@ -9,7 +9,7 @@ describe('Wrap', () => { describe('applied to a method', () => { it('should delegate to WrapOnMethod and wrap the method', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -26,7 +26,7 @@ describe('Wrap', () => { }); it('should set WRAP_APPLIED_KEY on the method descriptor', () => { - const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); + const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); class TestService { @Wrap(wrapFn) @@ -48,7 +48,7 @@ describe('Wrap', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -77,14 +77,14 @@ describe('Wrap', () => { const methodCalls: string[] = []; const classWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { classCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; }; const methodWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { methodCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -111,7 +111,7 @@ describe('Wrap', () => { }); it('should return the constructor when applied to a class', () => { - const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); + const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); @Wrap(wrapFn) class TestService { @@ -129,7 +129,7 @@ describe('Wrap', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -165,7 +165,7 @@ describe('Wrap', () => { it('should not wrap the constructor', () => { const wrapFnSpy = vi.fn((_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }); @Wrap(wrapFnSpy) @@ -199,7 +199,7 @@ describe('Wrap', () => { const wrapFn: WrapFn = (context) => { receivedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -223,7 +223,7 @@ describe('Wrap', () => { const wrapFn: WrapFn = (context) => { receivedWrapCtx = context; - return (invCtx, method) => { + return (method, invCtx) => { receivedInvCtx = invCtx; return method(...invCtx.args); }; @@ -255,7 +255,7 @@ describe('Wrap', () => { const wrapFn: WrapFn = (context) => { receivedContext = context as unknown as Record; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -276,7 +276,7 @@ describe('Wrap', () => { let receivedMethod: ((...args: unknown[]) => unknown) | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { receivedMethod = method; return method(...invCtx.args); }; @@ -304,7 +304,7 @@ describe('Wrap', () => { it('should bind method to the correct instance for each invocation', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -327,7 +327,7 @@ describe('Wrap', () => { describe('sync method through Wrap', () => { it('should wrap a sync method and return its result unchanged', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class Calculator { @@ -343,7 +343,7 @@ describe('Wrap', () => { it('should allow Wrap to modify the sync return value', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { const result = method(...invCtx.args) as number; return result * 10; }; @@ -362,7 +362,7 @@ describe('Wrap', () => { it('should allow Wrap to intercept arguments for sync methods', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { // Intercept: double all numeric arguments const doubled = invCtx.args.map((a) => typeof a === 'number' ? a * 2 : a, @@ -387,7 +387,7 @@ describe('Wrap', () => { describe('async method through Wrap', () => { it('should wrap an async method and return its resolved value', async () => { const wrapFn: WrapFn = (_context) => { - return async (invCtx, method) => { + return async (method, invCtx) => { const result = await method(...invCtx.args); return result; }; @@ -408,7 +408,7 @@ describe('Wrap', () => { it('should allow Wrap to modify the async return value', async () => { const wrapFn: WrapFn = (_context) => { - return async (invCtx, method) => { + return async (method, invCtx) => { const result = (await method(...invCtx.args)) as { id: number; name: string }; return { ...result, modified: true }; }; @@ -429,7 +429,7 @@ describe('Wrap', () => { it('should propagate errors from async methods', async () => { const wrapFn: WrapFn = (_context) => { - return async (invCtx, method) => { + return async (method, invCtx) => { return method(...invCtx.args); }; }; @@ -454,14 +454,14 @@ describe('Wrap', () => { const calls: string[] = []; const wrapFnA: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(`A:${String(context.propertyKey)}`); return method(...invCtx.args); }; }; const wrapFnB: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(`B:${String(context.propertyKey)}`); return method(...invCtx.args); }; @@ -489,14 +489,14 @@ describe('Wrap', () => { const methodCalls: string[] = []; const classWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { classCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; }; const methodWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { methodCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -529,7 +529,7 @@ describe('Wrap', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -557,7 +557,7 @@ describe('Wrap', () => { it('should mark method with exclusionKey when applied at method level', () => { const EXCLUSION_KEY = Symbol('customKey'); - const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); + const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); class TestService { @Wrap(wrapFn, EXCLUSION_KEY) @@ -579,7 +579,7 @@ describe('Wrap', () => { describe('invalid decorator context', () => { it('should throw Error when applied in an unsupported context', () => { - const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); + const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); const decorator = Wrap(wrapFn); @@ -590,7 +590,7 @@ describe('Wrap', () => { }); it('should throw Error with propertyKey present but descriptor missing', () => { - const wrapFn: WrapFn = (_context) => (invCtx, method) => method(...invCtx.args); + const wrapFn: WrapFn = (_context) => (method, invCtx) => method(...invCtx.args); const decorator = Wrap(wrapFn); diff --git a/tests/WrapOnClass.spec.ts b/tests/WrapOnClass.spec.ts index 4b26b08..a968448 100644 --- a/tests/WrapOnClass.spec.ts +++ b/tests/WrapOnClass.spec.ts @@ -11,7 +11,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -42,7 +42,7 @@ describe('WrapOnClass', () => { it('should preserve correct return values from wrapped methods', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -65,7 +65,7 @@ describe('WrapOnClass', () => { describe('skips constructor', () => { it('should not fire the wrapper during construction', () => { const wrapFnSpy = vi.fn((_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }); @WrapOnClass(wrapFnSpy) @@ -97,7 +97,7 @@ describe('WrapOnClass', () => { const wrapFn: WrapFn = (context) => { wrappedNames.push(String(context.propertyKey)); - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -130,7 +130,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -166,7 +166,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -195,7 +195,7 @@ describe('WrapOnClass', () => { let stored = 0; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -226,7 +226,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -257,7 +257,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -295,7 +295,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -326,7 +326,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -359,14 +359,14 @@ describe('WrapOnClass', () => { const methodCalls: string[] = []; const classWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { classCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; }; const methodWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { methodCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -399,14 +399,14 @@ describe('WrapOnClass', () => { const methodCalls: string[] = []; const classWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { classCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; }; const methodWrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { methodCalls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -439,7 +439,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -467,7 +467,7 @@ describe('WrapOnClass', () => { it('should set WRAP_APPLIED_KEY metadata on methods it wraps', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -490,7 +490,7 @@ describe('WrapOnClass', () => { const CUSTOM_KEY = Symbol('custom'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -513,7 +513,7 @@ describe('WrapOnClass', () => { const CUSTOM_KEY = Symbol('custom'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn, CUSTOM_KEY) @@ -541,14 +541,14 @@ describe('WrapOnClass', () => { const callsB: string[] = []; const wrapFnA: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { callsA.push(String(context.propertyKey)); return method(...invCtx.args); }; }; const wrapFnB: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { callsB.push(String(context.propertyKey)); return method(...invCtx.args); }; @@ -575,7 +575,7 @@ describe('WrapOnClass', () => { describe('this binding is preserved', () => { it('should preserve this context in wrapped methods', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; @WrapOnClass(wrapFn) @@ -599,7 +599,7 @@ describe('WrapOnClass', () => { const wrapFn: WrapFn = (context) => { capturedWrapContexts.push(context); - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -639,7 +639,7 @@ describe('WrapOnClass', () => { const calls: string[] = []; const wrapFn: WrapFn = (context) => { - return (invCtx, method) => { + return (method, invCtx) => { calls.push(String(context.propertyKey)); return method(...invCtx.args); }; diff --git a/tests/WrapOnMethod.spec.ts b/tests/WrapOnMethod.spec.ts index ec06340..01fcd4d 100644 --- a/tests/WrapOnMethod.spec.ts +++ b/tests/WrapOnMethod.spec.ts @@ -15,7 +15,7 @@ describe('WrapOnMethod', () => { describe('basic wrapping', () => { it('should call wrapFn once at decoration time with WrapContext', () => { const wrapFnSpy = vi.fn((_context) => { - return (_invCtx, method) => method(..._invCtx.args); + return (method, _invCtx) => method(..._invCtx.args); }); class TestService { @@ -42,7 +42,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (_context) => { wrapCount++; - return (invCtx, method) => { + return (method, invCtx) => { callCount++; return method(...invCtx.args); }; @@ -78,7 +78,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (_context) => { wrapCount++; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -107,7 +107,7 @@ describe('WrapOnMethod', () => { it('should return the result from innerFn', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { const result = method(...invCtx.args) as number; return result * 2; }; @@ -128,7 +128,7 @@ describe('WrapOnMethod', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -148,7 +148,7 @@ describe('WrapOnMethod', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedMethod = method; return method(...invCtx.args); }; @@ -178,7 +178,7 @@ describe('WrapOnMethod', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -203,7 +203,7 @@ describe('WrapOnMethod', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -228,7 +228,7 @@ describe('WrapOnMethod', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -257,7 +257,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -281,7 +281,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -307,7 +307,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -326,7 +326,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -353,7 +353,7 @@ describe('WrapOnMethod', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -372,7 +372,7 @@ describe('WrapOnMethod', () => { describe('exclusion key', () => { it('should set WRAP_KEY as default exclusion key', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -394,7 +394,7 @@ describe('WrapOnMethod', () => { const CUSTOM_KEY = Symbol('custom'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -416,7 +416,7 @@ describe('WrapOnMethod', () => { const CUSTOM_KEY = Symbol('custom'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -442,7 +442,7 @@ describe('WrapOnMethod', () => { const META_KEY = Symbol('testMeta'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -466,7 +466,7 @@ describe('WrapOnMethod', () => { const KEY_B = Symbol('b'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -491,7 +491,7 @@ describe('WrapOnMethod', () => { describe('sync method wrapping', () => { it('should pass through the return value unchanged when wrapper delegates', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -509,7 +509,7 @@ describe('WrapOnMethod', () => { const syncError = new Error('sync failure'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -527,7 +527,7 @@ describe('WrapOnMethod', () => { describe('async methods', () => { it('should work with async methods', async () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -545,7 +545,7 @@ describe('WrapOnMethod', () => { it('should allow async wrapper to modify async results', async () => { const wrapFn: WrapFn> = (_context) => { - return async (invCtx, method) => { + return async (method, invCtx) => { const result = (await method(...invCtx.args)) as string; return `modified: ${result}`; }; @@ -568,7 +568,7 @@ describe('WrapOnMethod', () => { const asyncError = new Error('async failure'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { @@ -586,7 +586,7 @@ describe('WrapOnMethod', () => { describe('method decorator return type', () => { it('should return a valid MethodDecorator', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; const decorator = WrapOnMethod(wrapFn); @@ -595,7 +595,7 @@ describe('WrapOnMethod', () => { it('should replace descriptor.value with the wrapped function', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; class TestService { diff --git a/tests/hook-types.spec.ts b/tests/hook-types.spec.ts index 7c899be..369461a 100644 --- a/tests/hook-types.spec.ts +++ b/tests/hook-types.spec.ts @@ -84,9 +84,9 @@ describe('hook.types', () => { }); describe('WrapFn', () => { - it('accepts WrapContext and returns a function taking InvocationContext and method', () => { + it('accepts WrapContext and returns a function taking method and InvocationContext', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { return method(...invCtx.args); }; }; @@ -108,12 +108,12 @@ describe('hook.types', () => { args: [42], argsObject: undefined, }; - expect(factory(invCtx, fakeMethod)).toBe(42); + expect(factory(fakeMethod, invCtx)).toBe(42); }); it('supports generic return type parameter', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { return (method(...invCtx.args) as number) * 2; }; }; @@ -135,7 +135,7 @@ describe('hook.types', () => { args: [], argsObject: undefined, }; - expect(factory(invCtx, fakeMethod)).toBe(42); + expect(factory(fakeMethod, invCtx)).toBe(42); }); }); diff --git a/tests/wrapFunction.spec.ts b/tests/wrapFunction.spec.ts index 3354cc7..7bbe46d 100644 --- a/tests/wrapFunction.spec.ts +++ b/tests/wrapFunction.spec.ts @@ -15,7 +15,7 @@ describe('wrapFunction', () => { describe('basic wrapping', () => { it('should call wrapFn once at wrap time', () => { const wrapFnSpy = vi.fn((_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }); function greet(name: string) { @@ -48,7 +48,7 @@ describe('wrapFunction', () => { const wrapFn: WrapFn = (_context) => { wrapCount++; - return (invCtx, method) => { + return (method, invCtx) => { callCount++; return method(...invCtx.args); }; @@ -90,7 +90,7 @@ describe('wrapFunction', () => { const wrapFn: WrapFn = (_context) => { wrapCount++; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; function doWork() { @@ -124,7 +124,7 @@ describe('wrapFunction', () => { it('should return the result from the inner function', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { const result = method(...invCtx.args) as number; return result * 2; }; @@ -150,7 +150,7 @@ describe('wrapFunction', () => { describe('this binding', () => { it('should bind original method to the correct this context', () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; const original: (...args: unknown[]) => unknown = function ( @@ -175,7 +175,7 @@ describe('wrapFunction', () => { let capturedMethod: ((...args: unknown[]) => unknown) | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedMethod = method; return method(...invCtx.args); }; @@ -209,7 +209,7 @@ describe('wrapFunction', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; function greet(name: string, greeting: string) { @@ -236,7 +236,7 @@ describe('wrapFunction', () => { const wrapFn: WrapFn = (context) => { capturedContext = context; - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; function doWork(x: number) { @@ -265,7 +265,7 @@ describe('wrapFunction', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -295,7 +295,7 @@ describe('wrapFunction', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -325,7 +325,7 @@ describe('wrapFunction', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -356,7 +356,7 @@ describe('wrapFunction', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -386,7 +386,7 @@ describe('wrapFunction', () => { let capturedInvCtx: InvocationContext | undefined; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvCtx = invCtx; return method(...invCtx.args); }; @@ -417,7 +417,7 @@ describe('wrapFunction', () => { const capturedInvContexts: InvocationContext[] = []; const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => { + return (method, invCtx) => { capturedInvContexts.push(invCtx); return method(...invCtx.args); }; @@ -451,7 +451,7 @@ describe('wrapFunction', () => { describe('async methods', () => { it('should work with async methods', async () => { const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; async function fetchData(id: number): Promise { @@ -474,7 +474,7 @@ describe('wrapFunction', () => { it('should allow async wrapper to modify async results', async () => { const wrapFn: WrapFn> = (_context) => { - return async (invCtx, method) => { + return async (method, invCtx) => { const result = (await method(...invCtx.args)) as string; return `modified: ${result}`; }; @@ -502,7 +502,7 @@ describe('wrapFunction', () => { const asyncError = new Error('async failure'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; async function failingAsync() { @@ -527,7 +527,7 @@ describe('wrapFunction', () => { const syncError = new Error('sync failure'); const wrapFn: WrapFn = (_context) => { - return (invCtx, method) => method(...invCtx.args); + return (method, invCtx) => method(...invCtx.args); }; function failing(): never {