Repository navigation
Description
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):
- The exported class is not a member of the namespace object:
N.Plainisundefined,Object.keys(N)lists only the non-class members. - Static field initializers never run:
static p = 7reads0even from inside the namespace. - A call-expression heritage is not registered (
class Service extends Context.Service<…>()(id) {}): inside the namespaceService.keyisundefined,typeof Service.ofis"undefined", andService.of(x)throwsof 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
proggeramlug commentedon Sep 13, 2026
Fix: PR #10231 (perry-hir namespace arm now mirrors the module-level class path: member publication in source order, static init / computed names / decorators, dynamic parent registration, qualified namespace-local class names). Verified on the build host: the repro above and the FSUtil-shaped probe print the bun output; new tests
crates/perry/tests/namespace_classes.rs(4) +crates/perry-hir/tests/namespace_classes.rs;cargo test -p perry-hir664 passed. Not covered: dottednamespace A.B {}declarations and destructured namespace exports (unused by OpenCode).fix(hir): exported namespace functions are first-class inside the nam…
fix(hir): lower classes declared inside TypeScript namespaces complet…
fix(hir): exported namespace functions are first-class inside the nam…
proggeramlug commentedon Sep 14, 2026
Fixed by merge train 185r (#10242) at 9fda98d.