Skip to content

Computed-key static class fields leak their synthetic name into cross-module external-global declarations #5984

Description

@proggeramlug

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.

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