feat!: Stage-3 migration#19
Closed
Zendrex wants to merge 118 commits into
Closed
Conversation
Implements registerCtor, resolveCtorFromMetadata, getCorrelationFor, queueDeferred, hasPendingFor, and flushFor to wire up the pending decoration buffer and bidirectional ctor↔correlation WeakMaps (C5).
Remove DecoratedConstructorParameter, DecoratedMethodParameter, DecoratedParameter, the "constructor-parameter"/"method-parameter" discriminators, and the parameters() method on ScopedReflector. Downstream callsites will be updated in Phase L.
Rewrites createClassDecorator to use Stage-3 ClassDecoratorContext, adds TInstance generic constraint, and wires registerCtor/flushFor for Symbol.metadata correlation. Adds v0.x bridge stubs to metadata/store and factories/shared so legacy factory files remain importable while awaiting Stage-3 rewrites. Fixes reflector.ts to use collectClassMeta/getMemberMeta instead of removed reflect-metadata helpers. Removes broken store re-exports from index.ts and drops reflect import until the reflector is fully migrated. Type errors: 100 → 47. Tests: 5 pass, 1 skip (reflect L2 gated).
…constraint" This reverts commit 4a7596d.
…requisite) Combines plan Task L1 (drop parameters() from ScopedReflector) and L2 Step 3 (rewrite reflector.ts to use new store APIs + auto-materialize + UnregisteredClassError). Done ahead of Phase G-K because Bun's runtime module evaluation fails on reflector.ts's broken imports of removed store names, which blocks factory test runtime-load. L2 Step 1/2/4/5 (reflector.test.ts rewrite and its dedicated commit) deferred until after G1-K1 complete, per plan ordering for the test-level validation. EA-6 applied: isMethodLike classifies static accessors as method-like.
Parent-declared static members are inherited by child classes via the constructor prototype chain. `isMethodLike` and `isStaticMember` previously checked only the immediate constructor, misclassifying inherited statics as instance properties and reporting `static: false`. Mirror the existing instance-side chain walk for statics.
EA-3 mandates `any` (not `unknown`) for ClassMethodDecoratorContext's This generic to accept typed `this:` on methods without rejection by the lib.es2023.decorators.d.ts constraint.
…13 transpiler bug Bun 1.3.13 emits a shared `_init` variable per module for classes with decorated fields; the later class's initializer overwrites the earlier class's, causing A's @column initializer to never fire correctly. Store-level sibling isolation is already covered by store.test.ts.
…MethodInterceptor
Removed comment that merely restated what the conditional expression does, per project style guide: avoid narration comments when code is self-documenting. Kept Symbol descriptions in factories per linter (useSymbolDescription rule) and ecosystem debugging value—descriptions are not noise.
Replace unclear single-letter identifier with descriptive name for better readability and to match project style guidelines.
…efined Changes visit parameter return type from `unknown` to `boolean | undefined` to enforce compile-time clarity. This prevents accidental returns of arbitrary truthy values and makes the walk-termination contract explicit. All callers already comply with the stricter signature.
Two-overload helper for idiomatic get-or-create patterns on Map and WeakMap. Replaces 8 inline upsert sites across three metadata stores: - class-meta-store (2 sites) - member-meta-store (5 sites) - metadata-deferred-queue (1 site) Removes nesting and improves readability; single factory call on miss.
Both class-meta-store and member-meta-store repeated the same three prototype-chain walk shapes (any non-empty link, first list value, collect-all). Hoist them into store-walk.ts so each call site reads as intent rather than as a hand-rolled walk. Six sites collapsed: - class-meta-store: collectClassMeta, hasAnyClassMeta, firstClassMetaForKey, hasAnyClassMetaForKey - member-meta-store: collectMemberMeta, firstMemberMetaForKey, hasAnyMemberMeta, hasAnyMemberMetaForKey class-meta-store drops 107 -> 79 lines; member-meta-store drops 159 -> 132 lines. The remaining walkPrototypeChain uses in member-meta-store (getMemberStatic, collectMemberNames) consume Map keys/values rather than lists and stay specialized.
- I1: add once-per-file cast safety comment in class-meta-store and member-meta-store explaining the unknown[] → T[] cast is safe because callers parameterize reads with the type they put in - I2: reword firstOnChain JSDoc lead to unambiguously say "first element of the first non-empty list" (not "first value produced by walking") - M3: note subclass-first walk order in chainHasNonEmpty JSDoc, matching the other two helpers M1 was already satisfied — all three store-walk helpers carry explicit return types in the existing signatures.
Replace `MemberBucket = Map<symbol, Map<name, unknown[]>>` with `Map<symbol, Map<name, MemberEntry>>`, where `MemberEntry` carries the member's append-only `values` plus the `static` flag captured at first append. Drop the standalone `memberStaticStore` WeakMap and its chain walk in `getMemberStatic`. `getMemberStatic` now requires a `key` parameter; the only caller is the reflector, which already has the key in scope when iterating member names. The old name-only WeakMap had a latent silent-overwrite bug (two factories decorating the same name with different keys would clobber each other's static flag) — the per-key entry shape avoids it. Behavior is unchanged for public APIs. Removes one WeakMap and one chain walk per probe on the reflector hot path. Pairs with T2.3 which will expose `snapshotMembers` built on the new entry shape.
`values` is mutated via `entry.values.push(meta)` in `appendMemberMeta`. Keeping `readonly` there was misleading; `static` remains readonly as it is truly invariant for a given (key, name) pair.
Add snapshotMembers(ctor, key) which walks the prototype chain once and returns a Map<name, MemberEntry> with chain-merged values (subclass-first) and the most-derived static flag. Reflector.collectMembers now consumes the snapshot, replacing collectMemberNames + per-name getMemberStatic + per-name collectMemberMeta. Per collectMembers call this drops chain walks from 1+2N to 1; per all() call from 2+4N to 2. Behavior parity verified: same iteration order (chain walk), same merge order (subclass-first concat), same static semantics (most-derived defining link), same cardinality-aware metadata shape.
Both `ensureClassRegistered` (factories) and `ReflectorImpl.ensureRegistered` walked the prototype chain twice — once via `hasAnyClassMeta`, once via `hasAnyMemberMeta`. Replace with a single `hasAnyMeta` that walks the chain once and probes both stores per link, short-circuiting on first hit. Each store now exposes a tiny `hasOwnAny*Meta(ctor)` probe (own-only, no walk) so the combined helper can stay in its own module without leaking store internals. The chain-walking `hasAnyClassMeta` / `hasAnyMemberMeta` are removed — the two refactor sites were their only callers.
Reader helpers call prepare on every first/has/all access. Once a ctor's deferred metadata has been drained, mark it via a WeakSet sentinel so subsequent prepare(ctor) calls collapse to a single WeakSet lookup instead of WeakMap reads + flushFor. queueDeferred invalidates the sentinel for the registered ctor (if any) so fresh deferred work always re-flushes. The chain-walk fallback intentionally does not mark, since its early-stop semantics may leave deeper ancestors pending.
Assert isFullyPrepared(A) === true after first prepare(A) to directly prove the sentinel is written, making the short-circuit test non-vacuous. Add two chain-walk tests: one asserts prepare(B) with pending ancestor does NOT mark B fully prepared; the other proves a subsequent prepare(B) still drains newly queued ancestor work.
Per-call `new ReflectorImpl(ctor)` defeats the per-instance methodLikeCache and re-runs ensureRegistered (prepare + hasAnyMeta) on every reflect() call. Cache impls in a module-level WeakMap keyed by the resolved ctor so both short-circuits stay warm across calls.
Add two cache-correctness tests: (1) prove methodLikeCache is shared across reflect() calls via prototype mutation and spy-on-descriptor approaches; (2) verify cached ReflectorImpl with registered=false retries ensureRegistered after late decoration, confirming the UnregisteredClassError → success path works end-to-end.
Add formatSlot(target, memberName?) to reflector/class-name to produce the canonical "Class" or "Class.member" slot string used in error messages. Refactor DuplicateMetadataError, MissingMetadataError, InvalidDecorationTargetError, and ValidationError to share it instead of each rebuilding the slot inline. Quoting and " on " prefix stay at the call sites so pinned message formats are preserved.
Replace tautological targetDisplayName comparison with literal "<anonymous>" assertion so regressions in either function are independently caught. Add MissingMetadataError test for a symbol memberName, asserting the stringified form (Symbol(my-key)) appears in the error message.
Introduce defineAnnotateError(spec) so every concrete error declares its name, code, message format, and AnnotateError context fields in one declarative block. Subclasses with positional constructors or extra fields (DuplicateMetadataError, UnregisteredMetadataKeyError, InvalidDecorationTargetError) keep a thin wrapper; the rest become const + interface aliases over the factory output. Public API of every subclass is preserved: same constructor signatures, same instance fields (including InvalidDecorationTargetError.requiredBase), same .name strings, and unchanged instanceof chains. Add focused identity tests in tests/unit/errors.test.ts covering DuplicateMetadataError plus a cross-subclass invariant for ctor.name vs instance.name.
- AnnotateErrorContext now Pick<AnnotateErrorOptions, ...> (no drift) - Factory super() call uses spread (...spec.toContext(args)) - Rename `extract` -> `toContext` for clarity - MissingMetadataError, UnregisteredClassError, ValidationError as empty class extensions to restore nominal type narrowing
…ntKey helper Introduces a non-exported mintKey<TKey> that handles symbol creation and registry write; mintUniqueKey and mintListKey become typed one-liner wrappers, eliminating the duplicated body and consolidating the cast justification.
Share the validate→append→correlate→flush protocol between class-level and static-member decorations behind a single internal helper, so future decorator kinds inherit the same commit pipeline instead of duplicating it.
…ator doc Add focused unit tests for commitDecoration asserting validators run before append and that registerCtor/flushFor execute (proven via flushed deferred member meta). Also updates the createClassDecorator JSDoc to reference commitDecoration instead of the now-internal registerCtor + flushFor calls.
…ity fork Expose `mintMetadataKey<T, C>(cardinality, description?)` from the cardinality registry so generic builders can pass cardinality through as a type parameter rather than forking on `mintUniqueKey` vs `mintListKey`. The 5 factory files now mint with a single helper, each shrinking by one import. `mintUniqueKey`/`mintListKey` remain exported for end-user code where the cardinality-specific name reads more clearly at call sites.
…dundant cardinality generic Replace the single `mintMetadataKey<T, C extends Cardinality>` declaration with two overloads that narrow the return type from the literal argument alone (`"unique"` → UniqueMetadataKey<T>, `"list"` → ListMetadataKey<T>), removing the need for a second generic at every call site. Update all 10 factory call sites (class, method, method-interceptor, property, accessor-interceptor — unique and list variants each) and the cardinality-registry test to use the single-generic form. Add one assertion confirming overload narrowing compiles without a cast.
- Extract `requireCardinality` and `assertNotDuplicate` into `append-guards.ts` so `appendClassMeta`/`appendMemberMeta` share one copy of the registry-lookup + duplicate-check pair. - Add `readValues<T>` in `store-walk.ts` as the single trust boundary for the `unknown[] -> readonly T[]` brand cast. - Harden `getOrCreate` to use `has`/`set` so callers may legitimately store `undefined`. - Move correlation->ctor resolution out of `prepared-sentinel`: rename `invalidatePreparedFor(correlation)` to `invalidatePrepared(ctor)`; `queueDeferred` resolves the ctor itself. - Make `registerCtor` fail loudly when a correlation/ctor is already bound to a different partner (was silently first-wins). - Loosen `walkPrototypeChain` visit return to `boolean | void`. - Tighten JSDoc across the metadata module; fold runtime type assertions into `.test-d.ts` files and drop redundant duplicate test files.
- Make `DecoratorOptions.compose` conditionally required: when `TArgs`
widens beyond `[TMeta]`, callers must supply a mapper, removing the
unreachable `args[0] as TMeta` runtime fallback.
- Drop `TNewMethod`/`TNewValue` generics from `DecoratedMethodFactory`
and `DecoratedAccessorFactory` `derive`; the original method/value
shape is fixed by the parent factory.
- Add `CardinalityOf<F>` helper alongside `MetadataOf` / `ArgsOf` /
`ThisOf`, propagating `TCard` through `FactoryGenerics`.
- Export `AnyClass` from `factories/types` and reuse it in
`class-decorator` instead of inlining the structural shape.
- Extract `prepareFactoryShell` in `factories/shared` to deduplicate
`{ composeFn, label, validators }` setup across all four
`build*Factory` entries; add `compose` overloads for the identity
tuple vs configured-mapper paths.
- Polish doc comments across `errors`, `factories/*`, `metadata/types`,
`reflector/types`, and `index` for tightness and accuracy; tighten
`Deferred.key` to `MetadataKey`.
- README: clarify that instance-member validation/`requireInstanceOf`
fire on first `new` (or first reflective read), not at decoration
time, and update the `Tag` list-cardinality example to reflect
Stage-3 inner-first ordering.
- Consolidate `factories/types.test-d.ts`, `factories/types-list.test-d.ts`,
`metadata/types.test-d.ts`, and `reflector/types.test-d.ts` into a
single `tests/unit/types.test-d.ts`; refresh `MemberEntry`/`Deferred`
shapes used in store unit tests.
Owner
Author
|
This PR got out of hand as I continued to refine it. Final shape is avail at #20 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cut
1.0.0-alpha.1. TypeScript experimental decorators gone, TC39 Stage-3 decorators in.Big shift: metadata used
reflect-metadataandemitDecoratorMetadata. Now annotate owns its ownWeakMaps keyed by constructor;class[Symbol.metadata]only used as identity correlation channel. Removes whole class of "did you importreflect-metadatafirst?" footguns. Factory surface had drifted into pile ofcreateXDecorator/createXInterceptorfunctions, so collapsed onto two frozen registries:decorate.{class,method,property}andintercept.{method,accessor}. One import, one mental model.Runtime floor: Node >= 20.4 (native
Symbol.metadata) or transpiler shim. TypeScript >= 5.2.Public API
Factory registries
decorate.class<TMeta>(),decorate.method,decorate.propertyintercept.method,intercept.accessorFactory accessors
factory.has(target[, member])/factory.hasOwn(target[, member])factory.first(target[, member])/factory.firstOrThrow(target[, member])factory.all(target[, member])returns frozenMetadataArray<TMeta>factory.reader(target)for typed reader accessfactory.derive<TThis, ...>(options?)builds child factory sharing parent's metadata key. AcceptsPick<DecoratorOptions, "name" | "validate" | "requireInstanceOf">. Parent validator runs before child's;requireInstanceOfreplaces.Reflector
reflect(target)returnsReflector.ScopedReflectorshape unchanged.methodsScalar(),propertiesScalar(), withDecoratedMethodScalar<T>/DecoratedPropertyScalar<T>results.UnregisteredClassError.applied/appliedOwnpredicates stay safe, returnfalseon that path.Runtime
prepare(ctor)eagerly flushes pending instance-member metadata. Reflector and predicate accessors auto-prepare; call directly only when introspecting through non-annotate path.Decorator type constraints
TInstance/TField/TMethod/TValue/TThisgenerics.decorate.property<M, [M], number>()rejects application tobooleanfield at compile time instead of blowing up at runtime.TThis(slot 4) narrowsthis-shape decorator accepts. Defaults toany.Decorator options
validate(meta, context)runs after compose, before commit. Throwing aborts decoration. For instance members defers untilprepare(ctor)socontext.targetresolves to concrete class.requireInstanceOf: Baseis declarative sugar overvalidate. Rejects when target class not subclass ofBase; throwsInvalidDecorationTargetErrorwithrequiredBasepopulated.Type helpers
MetadataOf<F>,ArgsOf<F>,ThisOf<F>for consumer generics.Errors
All under
AnnotateErrorwithAnnotateErrorCodediscriminant:UnregisteredClassErrorfor reflecting class with no registered metadataDuplicateMetadataErrorforunique: trueviolationsMissingMetadataErrorforfirstOrThrowon absent metadataInvalidDecorationTargetErrorforrequireInstanceOfrejectionValidationErrorforvalidatehook rejection, original throwable onError.causeBreaking changes
Upgrading — these bite:
experimentalDecorators/emitDecoratorMetadatamust befalse.reflect-metadatapeer gone. Drop from deps.createParameterDecoratorand parameter reflector slice gone. Stage-3 has no parameter primitive; nothing to wrap.createPropertyInterceptornowintercept.accessor, requiresaccessorkeyword (or explicitget/setmembers). Plain fields still carry metadata viadecorate.property, just can't be intercepted.InterceptorContextdropsdescriptorandowner. Stage-3 context carriesname/static/kinddirectly.UnregisteredClassErrorinstead of returning empty view. Most callers want this; silent-empty-view hid bugs.prepare(ctor), or any reflector / predicate read forces materialization.ensurePropertyworkaround gone. Non-annotate introspection ("x" in Ctor.prototype,Object.keys(instance)) sees different results than before. Usefactory.has(ctor, name)orreflect(ctor).properties()to enumerate decorated members../factories/*no longer supported. Usedecorate/intercept.Internal cleanup worth flagging
metadatastore was one big file. Split into class store, member store, ctor correlation, deferred queue.AnnotateErrorwithcodediscriminant; consumers switch oncodeinstead ofinstanceof-ing five classes.materialize()nowprepare(); reflectorSingularvariants nowScalar. Both old names ambiguous.Symbol.metadatashim exposed as side-effect import at@zendrex/annotate/shimfor older runtimes.scripts/check-helper-imports.tsas build guard. Helper-package code leaking intodist/was recurring problem; CI catches now.Test plan
bun run check-typescleanbun testfull suite greentests/unit/factories/type-constraints.test-d.ts)reflectInstanceagainst every Stage-3 decorator kinddist/bun run buildoutput against downstream consumer before promoting alphaTry It Out
Test out the latest build of this PR as it's being developed by checking the comment below.
#19 (comment)