Found via a real-world sst/opencode source compile, once #5918/#5922/#5924/#5927/#5928 no longer blocked the compile earlier.
Repro
// fixtures/base.ts
export const entityKind: unique symbol = Symbol.for("entityKind");
export abstract class BaseSession {
static readonly [entityKind]: string = "BaseSession";
tag(): string { return "base"; }
}
// main.ts
import { entityKind } from "./fixtures/base";
import { BaseSession } from "./fixtures/base";
class EffectSession extends BaseSession {
static override readonly [entityKind]: string = "EffectSession";
tag(): string { return "effect"; }
}
console.log((BaseSession as any)[entityKind]);
console.log((EffectSession as any)[entityKind]);
console.log(new EffectSession().tag());
Node prints BaseSession / EffectSession / effect. Compiling with perry fails to link:
Undefined symbols for architecture arm64:
"_perry_static_fixtures_base_ts__BaseSession____computed_field_98_110", referenced from:
_main in ...
ld: symbol(s) not found for architecture arm64
Root cause
A computed-key static field (static [Symbol.for(...)] = init) gets a synthetic HIR name __computed_field_<span.lo>_<span.hi> (perry-hir/src/lower_decl/class_members.rs). module_globals_emit.rs's emission loop correctly skips creating a backing perry_static_<mod>__<class>__<field> global for these — computed fields are stored via a runtime side table instead (see the existing comment referencing #420/#894), so the defining module never emits this symbol.
But run_pipeline.rs's cross-module class-export metadata (the ImportedClass.static_field_names populated at 7 call sites) collects every static field's .name, including computed ones' synthetic placeholder, with no filter. When another module imports the class, module_globals_emit.rs's "register foreign static-field globals" loop takes that list at face value and declares an external global reference for the synthetic name — which the source module never defines, since it never had reason to know that specific field was computed-only "invisible" metadata.
This is invisible for a same-module scenario (no cross-module declaration needed) and for a class with no importers, which is presumably why the pervasive static readonly [entityKind] pattern in drizzle-orm (used on dozens of classes) never surfaced this until now — you need a subclass or consumer of the class in a different file that also needs the class's other (non-computed) exports, pulling the whole ImportedClass metadata across the module boundary.
Fix
Filter out computed-key fields (f.key_expr.is_none()) before collecting static_field_names at all 7 call sites in run_pipeline.rs, matching the filter module_globals_emit.rs already applies on the defining side.
Testing
- Minimal cross-module repro above: reproduces pre-fix, links and runs correctly post-fix (output matches Node exactly).
- Full 16-fixture namespace/effect regression suite: 16/16 MATCH, no change.
cargo test --release -p perry --bin perry: 669/670 (1 known flaky, resource-contention-only test, confirmed passing in isolation — unrelated to this change).
cargo test --release -p perry-codegen --lib: 152/152.
- Verified against the real opencode compile: this exact class of "undefined symbol referencing a computed-field synthetic name" (10+ distinct classes across
effect-drizzle-sqlite and drizzle-orm itself) is completely gone after the fix.
Found via a real-world
sst/opencodesource compile, once #5918/#5922/#5924/#5927/#5928 no longer blocked the compile earlier.Repro
Node prints
BaseSession/EffectSession/effect. Compiling with perry fails to link:Root cause
A computed-key static field (
static [Symbol.for(...)] = init) gets a synthetic HIR name__computed_field_<span.lo>_<span.hi>(perry-hir/src/lower_decl/class_members.rs).module_globals_emit.rs's emission loop correctly skips creating a backingperry_static_<mod>__<class>__<field>global for these — computed fields are stored via a runtime side table instead (see the existing comment referencing #420/#894), so the defining module never emits this symbol.But
run_pipeline.rs's cross-module class-export metadata (theImportedClass.static_field_namespopulated at 7 call sites) collects every static field's.name, including computed ones' synthetic placeholder, with no filter. When another module imports the class,module_globals_emit.rs's "register foreign static-field globals" loop takes that list at face value and declares anexternal globalreference for the synthetic name — which the source module never defines, since it never had reason to know that specific field was computed-only "invisible" metadata.This is invisible for a same-module scenario (no cross-module declaration needed) and for a class with no importers, which is presumably why the pervasive
static readonly [entityKind]pattern in drizzle-orm (used on dozens of classes) never surfaced this until now — you need a subclass or consumer of the class in a different file that also needs the class's other (non-computed) exports, pulling the wholeImportedClassmetadata across the module boundary.Fix
Filter out computed-key fields (
f.key_expr.is_none()) before collectingstatic_field_namesat all 7 call sites inrun_pipeline.rs, matching the filtermodule_globals_emit.rsalready applies on the defining side.Testing
cargo test --release -p perry --bin perry: 669/670 (1 known flaky, resource-contention-only test, confirmed passing in isolation — unrelated to this change).cargo test --release -p perry-codegen --lib: 152/152.effect-drizzle-sqliteanddrizzle-ormitself) is completely gone after the fix.