Skip to content

Classes declared inside a TypeScript namespace are lowered incompletely: not published on the namespace object, static fields uninitialized, call-expression heritage unregistered — OpenCode v1.18.30 runtime wall (fs-util.ts) #10222

Description

@proggeramlug

Summary

A class declared inside a TypeScript namespace block is lowered incompletely by perry-hir (crates/perry-hir/src/lower/module_decl/namespace.rs, the ast::Decl::Class arm only calls lower_class_decl + push_class_dedup), unlike a module-level class (lower/module_decl.rs ~1363, which also emits RegisterClassParentDynamic, the computed-name evaluations, the static-field init statements and decorator init):

  1. The exported class is not a member of the namespace object: N.Plain is undefined, Object.keys(N) lists only the non-class members.
  2. Static field initializers never run: static p = 7 reads 0 even from inside the namespace.
  3. A call-expression heritage is not registered (class Service extends Context.Service<…>()(id) {}): inside the namespace Service.key is undefined, typeof Service.of is "undefined", and Service.of(x) throws of is not a function.

This is the current runtime wall of OpenCode v1.18.30 (tracker #10107): packages/core/src/fs-util.ts declares namespace FSUtil { export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} … return Service.of({...}) }, and the FSUtil layer is built by AppRuntime for every command, so models, run, serve and the TUI all die with Error: Unexpected error / of is not a function (gdb: js_throw_type_error_not_a_function ← js_class_static_method_call ← perry_closure_opencode_packages_core_src_fs_util_ts). OpenCode has three such files (core/src/fs-util.ts, core/src/util/effect-flock.ts, core/src/ripgrep/binary.ts, six classes).

Repro

// ns-class.ts
const Proto: any = { of(self: any) { return self } }
const Key = function () { function K() {}; Object.setPrototypeOf(K, Proto); return function (key: string) { (K as any).key = key; return K } }
const t = (name: string, f: () => any) => { try { console.log(name, JSON.stringify(f())) } catch (e: any) { console.log(name, "THROW", e.message) } }
class Base { static of(x: any) { return x } static b = 1 }
export namespace N {
  export class Plain { static p = 7; static m() { return "m" } }
  export class FromBase extends Base {}
  export class FromCall extends (Key as any)()("@x/FromCall") {}
  export const inside = () => [typeof Plain, Plain.p, Plain.m(), typeof FromBase, FromBase.b, FromBase.of({ a: 1 }), typeof FromCall]
  export const ofInside = () => FromCall.of({ z: 1 })
  export const keyInside = () => (FromCall as any).key
}
t("C1 inside: Plain/FromBase/FromCall", () => N.inside())
t("C2 outside N.Plain", () => [typeof N.Plain, N.Plain.p, N.Plain.m()])
t("C3 outside N.FromBase", () => [typeof N.FromBase, N.FromBase.b, N.FromBase.of({ b: 2 })])
t("C4 outside N.FromCall", () => [typeof N.FromCall, (N.FromCall as any).key])
t("C5 outside N.FromCall.of", () => N.FromCall.of({ c: 3 }))
t("C6 keys of N", () => Object.keys(N))
t("C7 inside FromCall.of", () => N.ofInside())
t("C8 inside FromCall.key", () => N.keyInside())

bun 1.3.14 (bun run ns-class.ts; tsc/esbuild output behaves the same):

C1 inside: Plain/FromBase/FromCall ["function",7,"m","function",1,{"a":1},"function"]
C2 outside N.Plain ["function",7,"m"]
C3 outside N.FromBase ["function",1,{"b":2}]
C4 outside N.FromCall ["function","@x/FromCall"]
C5 outside N.FromCall.of {"c":3}
C6 keys of N ["Plain","FromBase","FromCall","inside","ofInside","keyInside"]
C7 inside FromCall.of {"z":1}
C8 inside FromCall.key "@x/FromCall"

perry 0.5.1544 + PR #10213 (Linux x86_64):

C1 inside: Plain/FromBase/FromCall ["function",0,"m","function",1,{"a":1},"function"]
C2 outside N.Plain THROW Cannot read properties of undefined (reading 'p')
C3 outside N.FromBase THROW Cannot read properties of undefined (reading 'b')
C4 outside N.FromCall THROW Cannot read properties of undefined (reading 'key')
C5 outside N.FromCall.of THROW Cannot read properties of undefined (reading 'of')
C6 keys of N ["inside","ofInside","keyInside"]
C7 inside FromCall.of THROW of is not a function
C8 inside FromCall.key undefined

A top-level class Top extends (Key as any)()("@x/Top") {} with Top.of({}) works, so this is specific to the namespace body path.

Expected

The namespace arm should lower an export class the way the module-level arm does: publish the class as a namespace member (static field / export), run its static-field initializers and computed member names in source order, register the dynamic parent (RegisterClassParentDynamic) when the heritage is not a plain identifier, and run decorator init. Non-exported classes inside the namespace must still be usable from the namespace's own functions.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions