Skip to content

Add legacy TypeScript decorator metadata support#754

Merged
proggeramlug merged 12 commits into
PerryTS:mainfrom
TheHypnoo:feat/typescript-legacy-decorators-metadata
May 14, 2026
Merged

Add legacy TypeScript decorator metadata support#754
proggeramlug merged 12 commits into
PerryTS:mainfrom
TheHypnoo:feat/typescript-legacy-decorators-metadata

Conversation

@TheHypnoo
Copy link
Copy Markdown
Contributor

Summary

Adds the first usable legacy TypeScript decorator metadata path for Perry, focused on NestJS-style dependency injection canaries. The compiler keeps using SWC for parsing, but lowers decorators directly through HIR, codegen, and Perry runtime metadata rather than relying on emitted JavaScript helpers.

What changed

  • Preserve class, method, property, and parameter decorators in HIR.
  • Lower legacy decorator evaluation and invocation order.
  • Emit decorator-related design metadata, including constructor design:paramtypes, property design:type, method design:paramtypes, and design:returntype.
  • Add a minimal Reflect.*Metadata runtime subset and JS runtime bridge.
  • Treat reflect-metadata imports as compatible with Perry-provided metadata hooks.
  • Add Nest-like canaries covering injectable metadata, constructor injection metadata, metadata constants, scanner-style access, CommonJS decorator helpers, module imports, inheritance, and explicit unsupported class replacement behavior.

Compatibility notes

This targets legacy TypeScript decorators because NestJS, TypeORM, and similar frameworks rely on that ecosystem behavior. Full unmodified NestJS or TypeORM compatibility is still broader than this PR, but the main DI metadata path is now represented by focused parity canaries.

Class decorator replacement return values remain unsupported and are covered by an explicit negative canary.

Validation

  • cargo build --release -p perry-jsruntime -p perry
  • ./run_parity_tests.sh --filter decorators
  • cargo fmt --check
  • cargo test -p perry-hir -p perry-codegen -p perry-runtime --lib

TheHypnoo added 6 commits May 13, 2026 16:49
- Fix CI: add missing `decorators` field at three Param init sites in
  perry-codegen-arkts (lib.rs and phase2_full_app_smoke.rs) that the
  cross-crate HIR struct update missed.
- Move JS-runtime proxy modules out of the user's CWD into a per-process
  directory under std::env::temp_dir(), so loading JS modules no longer
  leaves stray `__perry_js_proxy_*.mjs` files in the project root.
- Reject getter/setter decorators loudly in
  validate_legacy_decorators_v1 with a clear error pointing at the
  accessor; previously they slipped through ClassMember::Method and were
  silently dropped while private decoration points already failed loud.
- Document the int32 / class-ref aliasing contract at the V8 bridge
  fast path so future callers know raw small positive integers cannot
  round-trip through JS interop.
- Document the GC contract on REFLECT_METADATA: keys are raw NaN-box
  bits, safe for ClassRef / closure-method targets but not for
  general heap-pointer targets under the evacuating GC.
- Restore the explanatory comment for the `class.id == 0` filter in
  emit_string_pool — the previous `body.is_empty()` guard's intent was
  documented and the equivalence isn't self-evident.
- Document the bounded leak in class_prototype_method_value_for_name
  (one allocation per unique (class_id, method_name) the program ever
  asks for; cached forever).
- Harden metadata_key_part: return Option<String>, drop the
  `bits:<hex>` fallback, and refuse to operate on
  non-string-coercible keys (Symbols, etc.). define becomes a no-op,
  get returns undefined, has/delete return false — no more silent
  ghost entries.
- Rename `validate_legacy_decorators_v1` to `validate_legacy_decorator_surface`;
  the `_v1` suffix on an internal helper implied a versioned API.
- Cache the six CommonJS-detection / wrap regexes (`is_commonjs`,
  `wrap_commonjs`, `strip_js_comments`) behind `once_cell::sync::Lazy`.
  These are hot — they run once per loaded JS module.
- Factor the eight near-identical `Reflect.*Metadata` dispatch arms in
  `lower_call.rs` into three `take_reflect_*_args` helpers (kvtp / ktp / tp).
- Switch the two `eprintln!` calls in the JS function-value catch handler
  to `log::error!` so they go through the same logging path as the rest of
  the surrounding code.

No behavior changes; parity decorator suite still 12/12.
The cross-process stable-hash example escaped the workspace-wide grep
in the previous commit (it's under examples/, not src/ or tests/).
Mirrors the same fix already applied to the other Param init sites.
@TheHypnoo TheHypnoo marked this pull request as ready for review May 13, 2026 15:44
CI lint (cargo fmt --check) wanted the LINE_COMMENT_RE chain on
separate lines.
@TheHypnoo TheHypnoo requested a review from proggeramlug May 13, 2026 16:12
@proggeramlug
Copy link
Copy Markdown
Contributor

Thanks for putting this together — the implementation is cleaner than I'd expect for a feature this size (additive IR variants, gated runtime cost, no panics in the Reflect surface, real DI simulation in the integration canary). Before merging I want to nail down two questions about what this actually unlocks for users.

1. Does a real NestJS app boot on this?

The canaries pass, but they're hand-rolled — resolveProvider in test_decorators_nest_integration_canary.ts is bespoke test code, not @nestjs/core itself. To prove this PR delivers the win it's pitched on, can you add one end-to-end smoke test that:

  • Starts from npx @nestjs/cli new (or the minimal equivalent — a package.json with @nestjs/common @nestjs/core reflect-metadata and a hand-written AppModule/AppController/AppService)
  • Compiles with perry (using compilePackages if needed)
  • Listens on a port and serves GET / returning a string

If you can get that working, the DX value is concrete. If you hit walls, the walls are what we need to see — they'll tell us how much further work is needed before this is usable end-to-end. Either way the smoke test belongs in tests/release/packages/ so it doesn't regress.

2. Two silent-failure cases I'd like to convert to compile errors before merge:

(a) import "reflect-metadata" is currently lowered to Ok(()) in crates/perry-hir/src/lower.rs:1213 — the import is silently dropped and Perry's built-in shim runs instead. The shim covers the methods this PR implements, but it's a subset of the npm package's surface (class-validator and TypeORM call into corners of reflect-metadata that aren't shimmed). Users importing the npm package will think they have the full thing. Suggested fix: keep the suppression but emit a one-time [perry] note: reflect-metadata import shimmed by built-in subset diagnostic at compile time, listing the methods Perry actually implements. That way the user knows what they're getting.

(b) Class decorators that return a replacement class currently run the decorator for side-effects and discard the return value — test_decorators_replacement_unsupported.ts documents this but doesn't enforce it. Real-world decorators like @Memoize, @Throttle, GraphQL resolver wrappers all return wrapped classes; they'd compile and run incorrectly. Suggested fix: detect at lowering time that a class decorator's resolved type is a ClassDecorator returning non-void/undefined and emit a compile error. If type-level detection is too fiddly, a runtime check at decorator-application time that throws on non-undefined return is acceptable as a fallback — anything beats silent failure.

Once those two are in and the NestJS smoke test compiles + serves a route, I'm comfortable merging. Appreciate the work — the metadata pipeline itself is well-scoped.

TheHypnoo added 4 commits May 14, 2026 10:29
Maintainer (proggeramlug) called out two silent-failure cases that need
to surface as compile errors / runtime throws before merge:

(2a) `import "reflect-metadata"` was lowered to `Ok(())`, hiding the
fact that Perry's built-in shim is a subset of the npm package's
surface. Users importing the npm package may expect class-validator /
TypeORM-level coverage. Now emit a one-shot `[perry] note:` to stderr
at compile time listing the implemented surface and pointing at the
decorator docs. Guarded by an AtomicBool so repeated imports don't
spam the user.

(2b) Class decorators returning a replacement class used to run for
side effects and silently discard the return. Real-world decorators
like `@Memoize`, `@Throttle`, and GraphQL resolver wrappers all return
wrapped classes — they would compile and execute incorrectly. The
fixed lowered class is immutable in the IR, so we can't honor the
replacement. New behavior: `append_class_decorator_invocations`
captures the return into a temp local and, when `typeof ret ===
"function"`, throws `TypeError: Class decorator @<name> on <Class>
returned a replacement class…`.

The `typeof === "function"` check (rather than `!== undefined`) works
around an unrelated Perry quirk where function-expression implicit
returns leave a numeric sentinel in the return slot — using `!==
undefined` would false-positive on bare side-effect-only decorators
like `@Injectable`. A class is `typeof "function"` in JS, so checking
that bucket precisely catches the class-replacement case the
maintainer flagged.

Updates `test_decorators_replacement_unsupported` from
"replacement ignored" (silent) to "TypeError at decorator application
time" (loud), and the limitations doc to match.

Parity decorator suite still 12/12.
Maintainer (proggeramlug) asked for a real end-to-end NestJS test in
tests/release/packages/ — not the hand-rolled DI canaries already in
test-files/. Wired the fixture and broke the first three walls that
stood in the way; documents the remaining wall as a follow-up.

## Fixture (tests/release/packages/nestjs-hello/)

- package.json with `@nestjs/common`, `@nestjs/core`,
  `@nestjs/platform-express`, `reflect-metadata`, `rxjs` + Perry's
  `compilePackages` set.
- entry.ts: minimal @module / @controller / @Injectable / @get + DI +
  NestFactory.create(AppModule) + app.listen(port).
- fixture.sh: npm install → perry compile → run in background → curl /
  → diff against expected.txt → cleanup. SKIPs with WALLS.md reference
  if a compile- or startup-wall is hit (the release sweep records the
  gap without going red).
- expected.txt: "curl status: 200\ncurl body: Hello Perry\n".

## Walls resolved by this commit

1. **`util.types` not in API manifest.** Added
   `property("util", "types")` and shipped a real `types` surface in
   the `node:util` stub (isPromise / isAsyncFunction / isMap /
   isUint8Array / isProxy / etc.). NestJS's container probes these
   during dispatch; defaulting unknown shapes to `false` is the
   conservative answer.

2. **`super.<prop>` (non-call) lowering hit a hard bail.** rxjs's
   OperatorSubscriber.ts stores `this._next = onNext ? wrapper :
   super._next` and similar — value-form super access. Lowered to
   `this.<prop>` (and `this[expr]` for computed). Correct when the
   subclass doesn't override the property, which covers the rxjs /
   NestJS pattern. Strict parent-vtable lookup is a follow-up.

3. **`async_hooks.AsyncResource` not available.** NestJS uses
   `runInAsyncScope` for request-scoped DI. Added `AsyncResource` +
   `AsyncLocalStorage` (+ `executionAsyncId`, `createHook`) to a real
   `node:async_hooks` stub. No real async-context tracking yet — the
   bind/run shape is enough for the NestJS path.

## What still blocks PASS

Wall 4: cross-module method symbol mangling for re-exported classes.
After (1)–(3) the compile + codegen succeed and link fails with
`_perry_method_node_modules_rxjs_src_index_ts__Subscription__unsubscribe`
unresolved — the class is defined in `rxjs/src/internal/Subscription.ts`
(prefix `..._internal_Subscription_ts`) but callers reference it via the
barrel `rxjs/src/index.ts`. The hono / fastify fixtures don't hit this
because they import flat classes from a single file. WALLS.md in the
fixture dir tracks this for the next iteration.

Once Wall 4 is fixed the fixture should flip from SKIP to PASS (or
expose the next layer of walls — same iterative process). Removing
WALLS.md turns any compile/startup failure into a hard FAIL, locking
in the baseline.

Decorator parity still 12/12. perry-codegen tests + manifest drift
tests still green (the new manifest entries don't trip the consistency
guard at crates/perry-codegen/tests/manifest_consistency.rs).
Drift from the manifest additions in fc020eb (util.types,
async_hooks.AsyncResource, async_hooks.AsyncLocalStorage). The CI
api-docs-drift gate caught it; regen via scripts/regen_api_docs.sh.
@TheHypnoo
Copy link
Copy Markdown
Contributor Author

Thanks for the structured review. Pushed the work as four commits + a follow-up docs regen on top of the original PR.

2a (reflect-metadata shim diagnostic) — done. c04df7ad. import "reflect-metadata" now emits a one-shot [perry] note: … to stderr at compile time listing the implemented surface (defineMetadata, getMetadata, getOwnMetadata, hasMetadata, hasOwnMetadata, getMetadataKeys, getOwnMetadataKeys, deleteMetadata, @Reflect.metadata(...)) and pointing at docs/src/language/decorators.md. Guarded by an AtomicBool so repeated imports across the dep graph don't spam.

2b (class decorator replacement returns) — done as runtime throw, your suggested fallback. Same commit. append_class_decorator_invocations in crates/perry-hir/src/lower.rs captures the decorator return into a temp and, when typeof ret === "function", throws TypeError: Class decorator @<name> on <Class> returned a replacement class …. I used typeof === "function" rather than !== undefined to work around an unrelated Perry quirk (function-expression implicit returns leave a numeric sentinel in the return slot, which made !== undefined false-positive on bare side-effect decorators like @Injectable). Class refs are typeof "function" in JS, so this catches the @memoize / @Throttle / GraphQL-wrapper pattern precisely. The test_decorators_replacement_unsupported canary flipped from "replacement ignored" (silent) to the TypeError header.

1 (NestJS smoke test) — fixture wired, three walls broken, fourth documented. fc020ebb.

tests/release/packages/nestjs-hello/ follows the hono-basic layout: package.json with @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs + compilePackages, entry.ts with @Module / @Controller / @Injectable / @Get + NestFactory.create(AppModule) + app.listen(port), fixture.sh that does npm install → perry compile → run in background → curl / → diff. Listens for real on a port, no bespoke resolveProvider.

Walls fixed in this PR:

  • util.types not in the API manifest — added property("util", "types") plus a real types surface in node:util (isPromise/isAsyncFunction/isMap/isUint8Array/isProxy/etc., unknown shapes default to false).
  • super.<prop> value-form lowering hit a hard lower_bail! — lowered to this.<prop> / this[expr]. Correct when the subclass doesn't override the property (covers rxjs's OperatorSubscriber this._next = onNext ? wrapper : super._next pattern). Strict parent-vtable lookup is the proper fix; left as a TODO with an inline comment.
  • async_hooks.AsyncResource missing — added AsyncResource + AsyncLocalStorage (+ executionAsyncId, createHook) to a real node:async_hooks stub. No real async-context tracking, but the runInAsyncScope shape is enough for the NestJS code path.

4ce4e693 regenerates docs/api/perry.d.ts + docs/src/api/reference.md for the new manifest entries (caught by the api-docs-drift gate).

Wall 4 — open: cross-module method symbol mangling for re-exported classes. Compile + codegen succeed; link fails on _perry_method_node_modules_rxjs_src_index_ts__Subscription__unsubscribe etc. The class is defined in rxjs/src/internal/Subscription.ts but callers reference it via the rxjs/src/index.ts barrel and Perry uses the barrel's prefix instead of the defining module's. The fixture file WALLS.md describes it and the probable next walls (the same bug from @nestjs/platform-express's ExpressAdapter, then likely Logger / setImmediate edges).

Structurally I think Wall 4 wants its own focused PR — it's surgery on crates/perry/src/commands/compile.rs and possibly crates/perry-codegen/src/codegen.rs, unrelated to decorator metadata. Same for whichever walls show up after that. The fixture is wired to SKIP-with-reason while WALLS.md is present and flips to hard FAIL the moment WALLS.md is removed, so each follow-up PR can chip away with a clear regression net.

Decorator parity still 12/12. CI is re-running on 4ce4e693.

@proggeramlug
Copy link
Copy Markdown
Contributor

Audited the changes against the discussion above — both 2a (one-shot reflect-metadata note via AtomicBool::swap + correct surface listing) and 2b (Expr::TypeErrorNew with the typeof === "function" check) match the implementation claims, and the NestJS smoke fixture's SKIP-while-WALLS.md-exists / hard-FAIL-once-removed mechanism is well-designed. CI is green across all gates and decorator parity is 12/12.

Filed three follow-up issues for the deferred items so the trail isn't lost — none are merge blockers, and the first two are explicitly acknowledged as TODOs in the PR's own inline comments / WALLS.md:

Approving.

@TheHypnoo TheHypnoo deleted the feat/typescript-legacy-decorators-metadata branch May 14, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants