diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx
index 244b95e46..5d9bceb8a 100644
--- a/docs/src/app/cli/page.mdx
+++ b/docs/src/app/cli/page.mdx
@@ -59,7 +59,7 @@ fib.s
Different output kinds accumulate in `.scriptc/`; rebuilding a kind updates its file.
-The three source outputs require only Node. On macOS 15+ arm64, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v3 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed source runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. On macOS 15+ arm64, LLVM-tier builds emit the program object through the helper and link release-built runtime objects; explicit C, LLVM fallback, and sanitizer builds retain runtime C compilation.
+The three source outputs require only Node. On macOS 15+ arm64, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v4 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed source runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. On macOS 15+ arm64, LLVM-tier builds emit the program object through the helper and link release-built runtime objects; explicit C, LLVM fallback, and sanitizer builds retain runtime C compilation.
## scriptc run
diff --git a/docs/src/app/how-it-works/page.mdx b/docs/src/app/how-it-works/page.mdx
index 8dab03ba0..c42e4a5c6 100644
--- a/docs/src/app/how-it-works/page.mdx
+++ b/docs/src/app/how-it-works/page.mdx
@@ -14,7 +14,7 @@ TypeScript ──tsc: parse + typecheck──▶ lowering ──▶ typed IR ─
3. **Backends** — `--emit=c` writes readable C and stops; `--emit=llvm` writes textual LLVM IR and stops. Neither source-output command discovers or invokes a native toolchain. On supported macOS, Linux, and Windows hosts (and for WASI), `--emit=asm|obj` sends LLVM IR to a version-matched out-of-process LLVM 22 helper; it needs no clang or linker. Executable builds default to LLVM and can fall back to C on a native program outside the LLVM tier (one stderr note; `--backend llvm` pins it and fails with a diagnostic instead). The production wasm32-wasi target never falls back.
4. **Link** — release packages contain one precompiled object per runtime feature unit plus QuickJS, libregexp, zlib, and mbedTLS archives for every supported target. A hashed manifest maps IR feature gates to an ordered typed link plan, so binaries still pay only for what they use. The user needs the target platform linker and SDK/sysroot, but ordinary LLVM-tier builds compile no C. AddressSanitizer, explicit C builds, and LLVM refusals keep the external C-toolchain path.
-Program objects define main and leave their selected scr_* runtime functions undefined. The scr_runtime_abi_v3 reference is a strong link-time compatibility check. --print=native-link-info exposes the exact source runtime pack and link ordering for external builds; that object ABI is currently experimental and exact-runtime-version compatible, not semver-stable.
+Program objects define main and leave their selected scr_* runtime functions undefined. The scr_runtime_abi_v4 reference is a strong link-time compatibility check. --print=native-link-info exposes the exact source runtime pack and link ordering for external builds; that object ABI is currently experimental and exact-runtime-version compatible, not semver-stable.
Inspect any stage yourself:
diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx
index 8a90b4095..c400ac50d 100644
--- a/docs/src/app/limitations/page.mdx
+++ b/docs/src/app/limitations/page.mdx
@@ -37,6 +37,8 @@ const results = await Promise.all(jobs); // number[] — compiles
- `undefined` and `null` compile both as standalone values and as union arms. Optional record and class fields compile, as do optional/default/rest parameters.
- Numeric static fields support prefix/postfix `++` and `--` through the declaring class or an immutable alias. Updates through a subclass name remain unsupported because JavaScript creates a separate property on that subclass.
+- Class expressions inside functions preserve fresh constructor identity and shared captured variables across constructors, methods, accessors, and arrow fields. Local inheritance, static members, decorators, private brands, generic members, explicitly generic factories returning local constructors, and references through the class's internal name remain unsupported. Constructors can pass through `unknown` while preserving identity, `typeof`, `name`, and `length`, and an exact typed cast restores construction; construction or static-member access directly through `unknown` remains unsupported.
+- Runtime-length argument spreads compile for fixed parameters and rest parameters whose types support checked `unknown` conversion. Arguments evaluate in source order before defaults run, and surplus arguments still evaluate. Calls without any native parameter slot and parameter types outside that conversion domain remain unsupported.
- Symbol-keyed instance fields compile with an initializer or an unconditional constructor assignment when the key is a stable module-level `Symbol()`, `Symbol("literal")`, or `Symbol.for("literal")` binding. Equal registry keys share a field across modules. Reassigned keys, runtime-computed descriptions, uninitialized computed fields, distinct symbols with the same description in one class, copying symbol fields with rest/spread or `Object.assign`, and symbol-keyed access through `unknown` remain unsupported.
**Standard library**
diff --git a/docs/src/app/native-objects/page.mdx b/docs/src/app/native-objects/page.mdx
index 3c492500d..89dff9b62 100644
--- a/docs/src/app/native-objects/page.mdx
+++ b/docs/src/app/native-objects/page.mdx
@@ -13,7 +13,7 @@ surface may change before 1.0, so consumers must use the exact
`@scriptc/runtime` version reported by the same compiler installation. This
is stricter than semver compatibility.
-The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v3`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
+The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v4`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
## Machine-readable link information
diff --git a/packages/cli/README.md b/packages/cli/README.md
index e71f68239..bc6aef62f 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -32,7 +32,7 @@ Builds use a bounded persistent cache by default. Exact unchanged library builds
- `scriptc run ` — compile and run
- `scriptc coverage ` — what compiles statically, and why the rest doesn't
-`scriptc build app.ts --emit=ir|c|llvm|asm|obj` selects serialized typed IR, readable C, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v3` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
+`scriptc build app.ts --emit=ir|c|llvm|asm|obj` selects serialized typed IR, readable C, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v4` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
For embedder-hosted modules that are not installed npm packages, coverage can
map an exact bare specifier to a local declaration with repeatable
diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts
index 5e27753ea..95a01fc58 100644
--- a/packages/cli/test/native-link-info.test.ts
+++ b/packages/cli/test/native-link-info.test.ts
@@ -63,7 +63,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
- runtime_abi: { version: 3, marker: "scr_runtime_abi_v3" },
+ runtime_abi: { version: 4, marker: "scr_runtime_abi_v4" },
runtime_pack: {
kind: "source",
package: "@scriptc/runtime",
@@ -147,6 +147,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
const error = await execFileAsync("clang", [
"-target", "arm64-apple-macosx14.0.0", object, stub, "-o", join(dir, "bad"),
]).then(() => null, (failure: { stderr?: string }) => failure);
- expect(error?.stderr).toContain("scr_runtime_abi_v3");
+ expect(error?.stderr).toContain("scr_runtime_abi_v4");
});
});
diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts
index ccd6faa36..9bf1ae1d1 100644
--- a/packages/compiler/src/backend/c/exprs.ts
+++ b/packages/compiler/src/backend/c/exprs.ts
@@ -2659,6 +2659,13 @@ function emitCallExpr(
// address. The +1 retain is a no-op on immortals but keeps the
// owned-temps discipline uniform (the regexLit pattern).
const sym = emitter.classObjSym(e.className);
+ if (e.captures !== undefined) {
+ const value = emitter.newTemp(e.type, `scr_classobj_new(&${sym}, ${e.captures.length})`);
+ e.captures.forEach((id, slot) => {
+ emitter.line(`${value.name}->caps[${slot}] = scr_box_retain(${mangleLocal(id)});`);
+ });
+ return value;
+ }
return emitter.newTemp(e.type, `scr_classobj_retain(&${sym})`);
}
case "newValue": {
@@ -2678,8 +2685,8 @@ function emitCallExpr(
const args = e.args.map((a) => emitter.emitExpr(a));
for (const a of args) emitter.moveTemp(a); // the constructor owns its params
const paramTypes = ctor.params.slice(1).map((p) => cType(p.type).trim());
- const cast = `(void *(*)(${paramTypes.join(", ") || "void"}))`;
- const call = `(${cast}${callee.name}->ctor)(${args.map((a) => a.name).join(", ")})`;
+ const cast = `(void *(*)(ScrClassObj *${paramTypes.length ? ", " + paramTypes.join(", ") : ""}))`;
+ const call = `(${cast}${callee.name}->ctor)(${[callee.name, ...args.map((a) => a.name)].join(", ")})`;
const t = emitter.newTemp(e.type, `(${cType(e.type).trim()})${call}`);
if (newValueMayThrow(cls, emitter.classMeta.get(cls), emitter.mayThrow)) emitter.emitPendingCheck();
return t;
@@ -2690,6 +2697,9 @@ function emitCallExpr(
// sides are hierarchy members, so the operand has a vt word.
const v = emitter.emitExpr(e.value);
const target = emitter.emitExpr(e.classValue);
+ if (e.value.type.kind === "object" && emitter.classMeta.get(e.value.type.className)?.def.localCaptures !== undefined) {
+ return emitter.newTemp(e.type, `${v.name}->sc_class == ${target.name}`);
+ }
return emitter.newTemp(
e.type,
`${v.name}->vt->pre >= ${target.name}->pre && ${v.name}->vt->pre <= ${target.name}->post`,
diff --git a/packages/compiler/src/backend/c/shapes.ts b/packages/compiler/src/backend/c/shapes.ts
index f68bb6fcb..1ab37aac8 100644
--- a/packages/compiler/src/backend/c/shapes.ts
+++ b/packages/compiler/src/backend/c/shapes.ts
@@ -127,6 +127,9 @@ export interface StructShape {
// the record's trace must visit it).
const rcMembers = (s: StructShape): { member: string; type: IrType; name: string }[] => [
...s.fields.map((f) => ({ member: mangleField(f.name), type: f.type, name: f.name })),
+ ...(s.meta?.def.localCaptures !== undefined
+ ? [{ member: "sc_class", type: { kind: "classval" as const, className: s.meta.def.name }, name: "class environment" }]
+ : []),
...(s.indexValue
? [{ member: OVERFLOW_MEMBER, type: mapOf(STRING, s.indexValue), name: "[key: string] overflow" }]
: []),
@@ -163,6 +166,7 @@ export interface StructShape {
out.push("");
for (const s of shapes) {
out.push(`struct ${s.struct} { /* ${cCommentText(s.comment)} */`, ` size_t rc;`);
+ if (s.meta?.def.localCaptures !== undefined) out.push(` ScrClassObj *sc_class;`);
if (inHierarchy(s)) {
// The hierarchy prefix: base fields follow at identical offsets in
// every subclass, so vt must sit between rc and the field list.
@@ -607,9 +611,9 @@ function emitRecordCloneC(
if (!intervalMeta) throw new InternalCompilerError(`emitter bug: class object for ${className} names unknown family ${meta.def.genericOf ?? ""}`);
const nameSym = emitter.internLiteral(meta.def.jsName ?? "");
out.push(
- `static void *${mangleCtorThunk(className)}(${ctorThunkParams(emitter, className).decls || "void"});`,
+ `static void *${mangleCtorThunk(className)}(ScrClassObj *sc_class${ctorThunkParams(emitter, className).decls ? ", " + ctorThunkParams(emitter, className).decls : ""});`,
`static ScrClassObj ${sym} = { SIZE_MAX, ${intervalMeta.pre}, ${intervalMeta.post}, ` +
- `(void *)&${mangleCtorThunk(className)}, (const ScrStr *)&${nameSym} }; /* class ${className} */`,
+ `(void *)&${mangleCtorThunk(className)}, (const ScrStr *)&${nameSym}, 0, ${meta.def.jsLength ?? 0} }; /* class ${className} */`,
);
}
}
@@ -640,8 +644,11 @@ function emitRecordCloneC(
const struct = mangleClassStruct(className);
const lines = [
``,
- `static void *${mangleCtorThunk(className)}(${decls || "void"}) {`,
+ `static void *${mangleCtorThunk(className)}(ScrClassObj *sc_class${decls ? ", " + decls : ""}) {`,
` ${struct} *o = ${mangleClassNew(className)}();`,
+ ...(emitter.classMeta.get(className)?.def.localCaptures !== undefined
+ ? [` o->sc_class = scr_classobj_retain(sc_class);`]
+ : [` (void)sc_class;`]),
` ${mangleFunction(`%${className}.constructor`)}(${[`${mangleClassRetain(className)}(o)`, ...names].join(", ")});`,
];
if (emitter.mayThrow.has(`%${className}.constructor`)) {
@@ -710,6 +717,8 @@ function emitRecordCloneC(
* the type cannot participate in a cycle (see the constructor fixpoint). */
export function traceAdapterC(emitter: CEmitter, t: IrType): string | null {
switch (t.kind) {
+ case "classval":
+ return "scr_classobj_trace_v";
case "func":
return "scr_closure_trace_v";
case "union":
@@ -772,7 +781,7 @@ function emitRecordCloneC(
elem.kind === "netServer" || // server handles: scr_net_server_* adapters, no trace
elem.kind === "symbol" || // symbol identities: scr_sym_* adapters, no trace
elem.kind === "bigint" || // immutable numeric values: scr_bigint_* adapters
- elem.kind === "classval" || // class objects: no-op adapters, no trace (immortal statics)
+ elem.kind === "classval" || // local class objects own traced capture boxes
// Closures: scr_closure_* adapters + scr_closure_trace_v (always
// cycle-headered — captures can reach back through boxes).
elem.kind === "func" ||
diff --git a/packages/compiler/src/backend/c/stmts.ts b/packages/compiler/src/backend/c/stmts.ts
index 948f4cd33..19b4813c2 100644
--- a/packages/compiler/src/backend/c/stmts.ts
+++ b/packages/compiler/src/backend/c/stmts.ts
@@ -33,7 +33,7 @@ export function emitFunction(emitter: CEmitter, fn: IrFunction): void {
emitter.currentGenerator = fn.generator ?? null;
emitter.labelCounter = 0;
emitter.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
- emitter.captureIds = new Set((fn.captures ?? []).map((c) => c.localId));
+ emitter.captureIds = new Set([...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId));
emitter.integerLoopBindings.clear();
emitter.integerRanges = analyzeIntegerRanges(fn);
@@ -55,6 +55,12 @@ export function emitFunction(emitter: CEmitter, fn: IrFunction): void {
(fn.captures ?? []).forEach((c, i) => {
emitter.line(`ScrBox *${mangleLocal(c.localId)} = sc_env->caps[${i}]; /* captured ${c.name} */`);
});
+ (fn.classCaptures ?? []).forEach((c) => {
+ const self = fn.params[0]!;
+ const local = emitter.currentLocals.get(self.localId)!;
+ const receiver = local.boxed ? mangleRawParam(self.localId) : mangleLocal(self.localId);
+ emitter.line(`ScrBox *${mangleLocal(c.localId)} = ${receiver}->sc_class->caps[${c.slot}]; /* captured ${c.name} */`);
+ });
const paramIds = new Set(fn.params.map((p) => p.localId));
for (const local of fn.locals) {
diff --git a/packages/compiler/src/backend/c/walkers.ts b/packages/compiler/src/backend/c/walkers.ts
index 490a1332d..a4602ae20 100644
--- a/packages/compiler/src/backend/c/walkers.ts
+++ b/packages/compiler/src/backend/c/walkers.ts
@@ -807,6 +807,9 @@ export function jsonWriteHelper(emitter: CEmitter, t: IrType): string {
case "func":
d.push(` return d->kind == SCR_DYN_FUNC;`);
break;
+ case "classval":
+ d.push(` return scr_dyn_class_is(d, ${cStringLiteral(Buffer.from(key, "utf8"))});`);
+ break;
case "object":
if (t.className === "%Error") {
d.push(` return d->kind == SCR_DYN_OBJ && scr_dyn_obj_get(d, "%error", 6) != NULL;`);
@@ -1224,6 +1227,9 @@ export function jsonWriteHelper(emitter: CEmitter, t: IrType): string {
d.push(` if (d->kind != SCR_DYN_NUM) { scr_dyn_check_fail(path, ${want}, d); return 0; }`);
d.push(` return d->v.num;`);
break;
+ case "classval":
+ d.push(` return scr_dyn_class_check(d, ${cStringLiteral(Buffer.from(key, "utf8"))}, path);`);
+ break;
case "bool":
d.push(` if (d->kind != SCR_DYN_BOOL) { scr_dyn_check_fail(path, ${want}, d); return false; }`);
d.push(` return d->v.b;`);
@@ -1548,6 +1554,9 @@ export function jsonWriteHelper(emitter: CEmitter, t: IrType): string {
// immutable-through-copies.
d.push(` return scr_dyn_retain(v);`);
break;
+ case "classval":
+ d.push(` return scr_dyn_new_class(v, ${cStringLiteral(Buffer.from(key, "utf8"))});`);
+ break;
case "func":
// Function-valued record/array fields (EventListenerObject's
// handleEvent method) box through the same identity-preserving
@@ -1654,6 +1663,7 @@ export function jsonWriteHelper(emitter: CEmitter, t: IrType): string {
if (cyclicArr) d.push(` scr_dyn_from_enter(v);`);
d.push(` ScrDyn *d = scr_dyn_new_arr();`);
d.push(` for (size_t i = 0; i < v->len; i++) {`);
+ d.push(` if (scr_arr_state(v, (double)i) == SCR_ARR_UNDEFINED) { scr_dyn_arr_push(d, scr_dyn_retain(scr_dyn_undefined())); continue; }`);
if (elem.kind === "f64") {
d.push(` scr_dyn_arr_push(d, scr_dyn_new_num(scr_arr_get_f64(v, (double)i)));`);
} else if (elem.kind === "bool") {
diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts
index f1c0c3521..e5293df81 100644
--- a/packages/compiler/src/backend/cycle-analysis.ts
+++ b/packages/compiler/src/backend/cycle-analysis.ts
@@ -18,9 +18,11 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
- fields: c.name === RUNTIME_EMITTER_CLASS
- ? [...c.fields, { name: "", type: funcOf([], VOID) }]
- : c.fields,
+ fields: [
+ ...c.fields,
+ ...(c.name === RUNTIME_EMITTER_CLASS ? [{ name: "", type: funcOf([], VOID) }] : []),
+ ...(c.localCaptures !== undefined ? [{ name: "", type: { kind: "classval" as const, className: c.name } }] : []),
+ ],
})),
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
@@ -59,6 +61,7 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
const cycleCapable = (t: IrType): boolean => {
switch (t.kind) {
case "func":
+ case "classval":
case "promise":
return true;
case "object":
diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts
index e4ac5f908..a5bc990c9 100644
--- a/packages/compiler/src/backend/llvm/classes.ts
+++ b/packages/compiler/src/backend/llvm/classes.ts
@@ -174,6 +174,7 @@ function emitterRooted(meta: LlClassMeta): boolean {
* display name) on emitter-rooted classes, then the stream-state slot on
* stream-rooted ones. */
function fieldBase(meta: LlClassMeta): number {
+ if (meta.def.localCaptures !== undefined) return 2;
if (!meta.hierarchy) return 1;
if (streamRooted(meta)) return 5;
return emitterRooted(meta) ? 4 : 2;
@@ -259,7 +260,7 @@ export function emitClassShapes(
: emitterRooted(meta) ? ["ptr", "ptr", "ptr"]
: ["ptr"]
: [];
- const members = [...prefix, ...fieldTys];
+ const members = [...prefix, ...(cls.localCaptures !== undefined ? ["ptr"] : []), ...fieldTys];
typeDefs.push(
`%${mangleClassStruct(cls.name)} = type { ${host.sizeType}${members.length ? ", " + members.join(", ") : ""} } ` +
`; class ${cls.name}${meta.hierarchy ? " (vt at 1)" : ""}${streamRooted(meta) ? " (ScrStream prefix at 2)" : emitterRooted(meta) ? " (ScrEmitter prefix at 2)" : ""} { ${cls.fields.map((f) => llvmCommentText(f.name)).join("; ")} }`,
@@ -310,8 +311,13 @@ export function emitClassShapes(
const isEmitterRooted = emitterRooted(meta);
const isStreamRooted = streamRooted(meta);
const fieldIndex = (i: number): number => fieldBase(meta) + i;
- const refFields = cls.fields
- .map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) }))
+ const indexedFields = [
+ ...cls.fields.map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) })),
+ ...(cls.localCaptures !== undefined
+ ? [{ name: "class environment", type: { kind: "classval" as const, className: cls.name }, index: 1 }]
+ : []),
+ ];
+ const refFields = indexedFields
.filter((f) => isRefCounted(f.type));
const sizeOf = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 1) to ${host.sizeType})`;
// An embedded prefix slot (the emitter registry at 2, the stream
@@ -472,7 +478,7 @@ export function emitClassShapes(
// trace: visit exactly the cycle-capable fields; gcFree: release
// exactly the complement, then free (the trace/teardown complement
// contract in scr_runtime.h).
- const indexed = cls.fields.map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) }));
+ const indexed = indexedFields;
const tracedFields = indexed.filter((f) => traceAdapter(host, f.type) !== null);
const untracedRefFields = indexed.filter(
(f) => isRefCounted(f.type) && traceAdapter(host, f.type) === null,
@@ -543,16 +549,22 @@ export function emitClassObjDefs(
const params = ctor.params.slice(1);
const paramDecls = params.map((p, i) => `${llType(p.type)} %a${i}`).join(", ");
const ctorArgs = params.map((p, i) => `${llType(p.type)} %a${i}`);
+ if (meta.def.localCaptures !== undefined) host.declare(`declare ptr @scr_classobj_retain_v(ptr)`);
out.push(
- `define internal ptr @${mangleCtorThunk(className)}(${paramDecls}) ${FN_ATTRS} { ; construct thunk ${className}`,
+ `define internal ptr @${mangleCtorThunk(className)}(ptr %class${paramDecls ? ", " + paramDecls : ""}) ${FN_ATTRS} { ; construct thunk ${className}`,
`entry:`,
` %o = call ptr @${mangleClassNew(className)}()`,
+ ...(meta.def.localCaptures !== undefined ? [
+ ` %class.owned = call ptr @scr_classobj_retain_v(ptr %class)`,
+ ` %class.slot = getelementptr inbounds %${mangleClassStruct(className)}, ptr %o, i64 0, i32 1`,
+ ` store ptr %class.owned, ptr %class.slot`,
+ ] : []),
` %r = call ptr @${mangleClassRetain(className)}(ptr %o)`,
` call void @${mangleFunction(`%${className}.constructor`)}(${[`ptr %r`, ...ctorArgs].join(", ")})`,
` ret ptr %o`,
`}`,
`@${mangleClassObj(className)} = internal global %ScrClassObj ` +
- `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym} } ; class ${className}`,
+ `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym}, ${host.sizeType} 0, ${host.sizeType} ${meta.def.jsLength ?? 0} } ; class ${className}`,
``,
);
}
diff --git a/packages/compiler/src/backend/llvm/debug-info.ts b/packages/compiler/src/backend/llvm/debug-info.ts
index 4af5ae0aa..f7abd4856 100644
--- a/packages/compiler/src/backend/llvm/debug-info.ts
+++ b/packages/compiler/src/backend/llvm/debug-info.ts
@@ -61,7 +61,7 @@ export class LlvmDebugInfo {
const id = this.add(`distinct !DISubprogram(name: ${quoted(fn.name)}, linkageName: ${quoted(mangleFunction(fn.name))}, scope: ${file}, file: ${file}, line: ${pos.line}, type: ${signature}, scopeLine: ${pos.line}, spFlags: DISPFlagLocalToUnit | DISPFlagDefinition, unit: ${this.unit(pos.file).id})`);
const scopes: { loc: SrcLoc; id: string }[] = [];
this.lexicalScopes.set(id, scopes);
- const captures = new Set((fn.captures ?? []).map((c) => c.localId));
+ const captures = new Set([...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId));
const spans = fn.locals.filter((local) => !captures.has(local.id)).flatMap((local) => local.source ? [local.source.scope] : []);
// Outer scopes first; declarations in flattened IR still retain their
// source block, loop, catch, or function scope (including hoisted var).
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index 1abc011ad..923e04f5e 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -396,6 +396,13 @@ export class LlDyn {
case "func":
kindIs(DYN_KIND.FUNC);
break;
+ case "classval": {
+ this.host.declare(`declare zeroext i1 @scr_dyn_class_is(ptr, ptr)`);
+ const matched = B.tmp();
+ B.line(`${matched} = call zeroext i1 @scr_dyn_class_is(ptr %d, ptr ${this.host.cstr(key)})`);
+ B.terminate(`ret i1 ${matched}`);
+ break;
+ }
case "object": {
if (t.className !== "%Error") {
// Exact class capsules returned true before materialization.
@@ -606,6 +613,14 @@ export class LlDyn {
host.declare(`declare void @scr_dyn_check_fail(ptr, ptr, ptr)`);
const want = host.cstr(dynDesc(t, this.host.recordsById, this.host.unionsById));
const B = new BlockBuilder();
+ if (t.kind === "classval") {
+ host.declare(`declare ptr @scr_dyn_class_check(ptr, ptr, ptr)`);
+ const checked = B.tmp();
+ B.line(`${checked} = call ptr @scr_dyn_class_check(ptr %d, ptr ${host.cstr(key)}, ptr %path)`);
+ B.terminate(`ret ptr ${checked}`);
+ this.defs.push(`define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`, B.render(), `}`, ``);
+ return name;
+ }
if (isRefCounted(t) && t.kind !== "dyn") {
host.declare(`declare zeroext i1 @scr_dyn_typed_ref_is(ptr, ptr, ${host.sizeType})`);
host.declare(`declare ptr @scr_dyn_typed_ref_unbox(ptr)`);
@@ -1314,6 +1329,13 @@ export class LlDyn {
B.terminate(`ret ptr ${r}`);
break;
}
+ case "classval": {
+ host.declare(`declare ptr @scr_dyn_new_class(ptr, ptr)`);
+ const r = B.tmp();
+ B.line(`${r} = call ptr @scr_dyn_new_class(ptr %v, ptr ${host.cstr(key)})`);
+ B.terminate(`ret ptr ${r}`);
+ break;
+ }
case "func": {
const r = B.tmp();
B.line(
@@ -1489,6 +1511,20 @@ export class LlDyn {
const len = B.tmp();
B.line(`${len} = call double @scr_arr_len(ptr %v)`);
B.countedLoop(len, (i) => {
+ host.declare(`declare double @scr_arr_state(ptr, double)`);
+ const state = B.tmp();
+ const undefinedState = B.tmp();
+ const undefinedLabel = B.newLabel("tda.undefined");
+ const valueLabel = B.newLabel("tda.value");
+ const doneLabel = B.newLabel("tda.done");
+ B.line(`${state} = call double @scr_arr_state(ptr %v, double ${i})`);
+ B.line(`${undefinedState} = fcmp oeq double ${state}, 2.0`); // SCR_ARR_UNDEFINED
+ B.terminate(`br i1 ${undefinedState}, label %${undefinedLabel}, label %${valueLabel}`);
+ B.startBlock(undefinedLabel);
+ const retained = this.retainDyn(B, this.undef(B));
+ B.line(`call void @scr_dyn_arr_push(ptr ${d}, ptr ${retained})`);
+ B.terminate(`br label %${doneLabel}`);
+ B.startBlock(valueLabel);
if (elem.kind === "f64" || elem.kind === "bool") {
const acc = elem.kind;
const accTy = elem.kind === "f64" ? "double" : "i1";
@@ -1513,6 +1549,8 @@ export class LlDyn {
B.line(`call void @scr_dyn_arr_push(ptr ${d}, ptr ${conv})`);
B.line(`call void ${releaseSym(host, elem)}(ptr ${e})`);
}
+ B.terminate(`br label %${doneLabel}`);
+ B.startBlock(doneLabel);
});
if (cyclicArr) B.line(`call void @scr_dyn_from_leave()`);
B.terminate(`ret ptr ${d}`);
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index f376003fe..23aeb1111 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -89,7 +89,7 @@ import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/c
import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js";
import { RUNTIME_ABI_MARKER } from "../runtime-abi.js";
import { computeMayThrow } from "../c/may-throw.js";
-import { mangleArgPack, mangleAsyncSpawn, mangleClassObj, mangleFnClosure, mangleFunction, mangleGenDrop, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordStruct, mangleTrampoline, mangleWrapper } from "../mangle.js";
+import { mangleArgPack, mangleAsyncSpawn, mangleClassObj, mangleClassStruct, mangleFnClosure, mangleFunction, mangleGenDrop, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordStruct, mangleTrampoline, mangleWrapper } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl, llvmCommentText } from "./common.js";
@@ -1123,7 +1123,7 @@ export class LlEmitter {
// class-object shape { rc, pre, post, ctor, name } — field reads on
// builtin errors and classval loads GEP through these.
`%ScrError = type { ${this.sizeType}, ptr, ptr, ptr, ptr, ptr }`,
- `%ScrClassObj = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`,
+ `%ScrClassObj = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr, ${this.sizeType}, ${this.sizeType} }`,
// The runtime emitter prefix { rc, vt, reg, cls } — user subclasses
// embed it (classes.ts), and bare-emitter GEPs address through it.
`%ScrEmitter = type { ${this.sizeType}, ptr, ptr, ptr }`,
@@ -3102,7 +3102,7 @@ export class LlEmitter {
this.scopes = [];
this.jumpTargets = [];
this.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
- this.captureIds = new Set((fn.captures ?? []).map((c) => c.localId));
+ this.captureIds = new Set([...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId));
this.integerLoopBindings.clear();
this.integerRanges = analyzeIntegerRanges(fn);
this.chainSlots.clear();
@@ -3177,6 +3177,21 @@ export class LlEmitter {
B.line(`${box} = load ptr, ptr ${p}`);
B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; captured ${c.name}`);
});
+ (fn.classCaptures ?? []).forEach((c) => {
+ const self = fn.params[0]!;
+ if (self.type.kind !== "object") throw new InternalCompilerError("class captures require an instance receiver");
+ const classSlot = B.tmp();
+ const classValue = B.tmp();
+ const caps = B.tmp();
+ const slot = B.tmp();
+ const box = B.tmp();
+ B.line(`${classSlot} = getelementptr inbounds %${mangleClassStruct(self.type.className)}, ptr %p_${mangleLocal(self.localId)}, i64 0, i32 1`);
+ B.line(`${classValue} = load ptr, ptr ${classSlot}`);
+ B.line(`${caps} = getelementptr inbounds %ScrClassObj, ptr ${classValue}, i64 1`);
+ B.line(`${slot} = getelementptr inbounds ptr, ptr ${caps}, ${this.sizeType} ${c.slot}`);
+ B.line(`${box} = load ptr, ptr ${slot}`);
+ B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; class capture ${c.name}`);
+ });
// Params spill into their slots; the function scope owns refcounted
// params (callees own their params — callers passed +1). Boxed params
// allocate the shared binding and move the raw value in.
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index 95f57daad..4bf8d5306 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -3,7 +3,7 @@ import { InternalCompilerError } from "../../errors.js";
import { newValueMayThrow } from "../../ir/analysis.js";
import { isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted } from "../../ir/ir.js";
import { collectFfiRetainedOps, parseFfiCallbackKey } from "../ffi-callbacks.js";
-import { mangleClassNew, mangleClassRetain, mangleFnClosure, mangleFunction, mangleLocal, mangleVtStruct } from "../mangle.js";
+import { mangleClassNew, mangleClassRetain, mangleClassStruct, mangleFnClosure, mangleFunction, mangleLocal, mangleVtStruct } from "../mangle.js";
import { classStructSym } from "./classes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
@@ -479,6 +479,21 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
// address. The +1 retain is a no-op on immortals but keeps the
// owned-temps discipline uniform (the regexLit pattern).
const sym = host.classObjSym(e.className);
+ if (e.captures !== undefined) {
+ host.declare(`declare ptr @scr_classobj_new(ptr, ${host.sizeType})`);
+ const value = B.tmp();
+ B.line(`${value} = call ptr @scr_classobj_new(ptr @${sym}, ${host.sizeType} ${e.captures.length})`);
+ e.captures.forEach((id, index) => {
+ const box = host.loadBox(`%${mangleLocal(id)}`);
+ const owned = host.retainBox(box);
+ const caps = B.tmp();
+ const slot = B.tmp();
+ B.line(`${caps} = getelementptr inbounds %ScrClassObj, ptr ${value}, i64 1`);
+ B.line(`${slot} = getelementptr inbounds ptr, ptr ${caps}, ${host.sizeType} ${index}`);
+ B.line(`store ptr ${owned}, ptr ${slot}`);
+ });
+ return host.own({ name: value, type: e.type });
+ }
return host.own({ name: host.retainValue(`@${sym}`, e.type), type: e.type });
}
case "newValue": {
@@ -500,7 +515,7 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
.map((a, i) => `${host.llType(ctor.params[i + 1]!.type)} ${a.name}`)
.join(", ");
const t = B.tmp();
- B.line(`${t} = call ptr ${thunk}(${argList})`);
+ B.line(`${t} = call ptr ${thunk}(ptr ${callee.name}${argList ? ", " + argList : ""})`);
const out = host.own({ name: t, type: e.type });
if (newValueMayThrow(cls, host.classMeta.get(cls), host.mayThrow)) host.emitPendingCheck();
return out;
@@ -512,6 +527,15 @@ export function emitCallExpr(host: LlvmEmitterContext, e: ExprOf<"call" | "ffiCa
if (e.value.type.kind !== "object") throw new InternalCompilerError("llvm emitter bug: instanceOfValue on a non-object");
const v = host.emitExpr(e.value);
const target = host.emitExpr(e.classValue);
+ if (host.classMeta.get(e.value.type.className)?.def.localCaptures !== undefined) {
+ const slot = B.tmp();
+ const actual = B.tmp();
+ const result = B.tmp();
+ B.line(`${slot} = getelementptr inbounds %${mangleClassStruct(e.value.type.className)}, ptr ${v.name}, i64 0, i32 1`);
+ B.line(`${actual} = load ptr, ptr ${slot}`);
+ B.line(`${result} = icmp eq ptr ${actual}, ${target.name}`);
+ return { name: result, type: e.type };
+ }
const pre = host.loadVtPre(v.name, e.value.type.className);
const tprep = B.tmp();
const tpre = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts
index 61a806feb..6b8d2511f 100644
--- a/packages/compiler/src/backend/llvm/shapes.ts
+++ b/packages/compiler/src/backend/llvm/shapes.ts
@@ -94,8 +94,7 @@ export function retainSym(host: ShapeHost, t: IrType): string {
export function releaseSym(host: ShapeHost, t: IrType): string {
const stem = runtimeRcStem(t);
if (stem !== null) {
- // scr_classobj_release is header-inline only; LLVM calls its exported
- // `_v` wrapper. Every other runtime family exports the typed release.
+ // Class objects share the container adapter at direct release sites.
const suffix = t.kind === "classval" ? "_release_v" : "_release";
const release = `@${stem}${suffix}`;
host.declare(`declare void ${release}(ptr)`);
@@ -117,6 +116,9 @@ export function releaseSym(host: ShapeHost, t: IrType): string {
* their RC rows refuse first). */
export function traceAdapter(host: ShapeHost, t: IrType): string | null {
switch (t.kind) {
+ case "classval":
+ host.declare(`declare void @scr_classobj_trace_v(ptr, ptr, ptr)`);
+ return "@scr_classobj_trace_v";
case "func":
host.declare(`declare void @scr_closure_trace_v(ptr, ptr, ptr)`);
return "@scr_closure_trace_v";
@@ -208,7 +210,7 @@ export function arrNewCall(host: ShapeHost, elem: IrType, capText: string): stri
elem.kind === "map" || elem.kind === "set" || // scr_map_* adapters and typed key/value tracing
elem.kind === "symbol" || // symbol identities: scr_sym_* adapters, no trace
elem.kind === "bigint" || // immutable numeric values: scr_bigint_* adapters
- elem.kind === "classval" || // class objects: no-op adapters, no trace (immortal statics)
+ elem.kind === "classval" || // local class objects own traced capture boxes
elem.kind === "promise" || // promise entries (Promise.all inputs): full REF story
elem.kind === "child" || // spawned child handles: scr_child_* adapters, no trace
elem.kind === "netServer" || // server handles ([...set] drains): REF, no trace
diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts
index 31219ed4f..d53b1618a 100644
--- a/packages/compiler/src/backend/runtime-abi.ts
+++ b/packages/compiler/src/backend/runtime-abi.ts
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
-export const RUNTIME_ABI_VERSION = 3 as const;
-export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v3" as const;
+export const RUNTIME_ABI_VERSION = 4 as const;
+export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v4" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index bf9a7540b..eac670343 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -34,7 +34,7 @@ async function fixture(target: NativeTargetSpec) {
const manifest: RuntimePackManifest = {
schema: "scriptc.runtime-pack.v1", format: 1, package: target.runtimePackPackage, version: compilerReleaseVersion(),
target: { name: target.name, llvm_triple: target.llvmTriple, architecture: target.architecture, object_format: target.objectFormat, minimum_os: target.minimumOs },
- runtime_abi: { version: 3, marker: "scr_runtime_abi_v3" },
+ runtime_abi: { version: 4, marker: "scr_runtime_abi_v4" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index 2675b6890..5a704e9bd 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -102,7 +102,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
- runtime_abi: { version: 3, marker: "scr_runtime_abi_v3" },
+ runtime_abi: { version: 4, marker: "scr_runtime_abi_v4" },
compiler: {
command: "clang",
identity: "fixture clang",
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index 908c14d19..be793505c 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -470,6 +470,54 @@ function defaultParameterShape(lowerer: Lowerer, param: ts.ParameterDeclaration,
* coercion an ordinary argument gets (coerceInto against its shape,
* DYN conversion in a dyn rest, element coercion in a typed rest). */
leading?: readonly IrExpr[],): IrExpr[] {
+ if (canCompleteRuntimeSpread(lowerer, shapes) &&
+ argNodes.some((arg) => ts.isSpreadElement(arg) && !fixedTupleSpreadInfo(lowerer, arg.expression))) {
+ // Runtime-length spreads determine the complete argument list before
+ // any parameter default runs. Build it once in source order, then
+ // extract the fixed native ABI slots; missing elements are undefined.
+ const pack = lowerer.declareHiddenLocal("%spreadArgs", DYN);
+ const ref = (): IrExpr => ({ kind: "varRef", localId: pack.id, type: DYN, loc });
+ const stmts: IrStmt[] = [{ kind: "varDecl", localId: pack.id, init: { kind: "dynArrLit", elems: [], type: DYN, loc }, loc }];
+ const push = (value: IrExpr, fn: "dyn.packPush" | "dyn.packPushSpreadIter") => {
+ stmts.push({ kind: "exprStmt", expr: { kind: "libCall", fn, args: [ref(), value], type: VOID, loc: value.loc }, loc: value.loc });
+ };
+ for (const value of leading ?? []) push(lowerer.coerceInto(blame, value, DYN), "dyn.packPush");
+ const spreads = argNodes.filter(ts.isSpreadElement).length;
+ argNodes.forEach((arg, index) => {
+ if (!ts.isSpreadElement(arg)) {
+ push(lowerer.lowerExprExpecting(arg, DYN), "dyn.packPush");
+ return;
+ }
+ let value = lowerer.lowerExpr(arg.expression);
+ if (value.type.kind === "set") {
+ value = { kind: "setIntrinsic", method: "toArray", receiver: value, args: [], type: arrayOf(value.type.elem), loc: value.loc };
+ } else if (value.type.kind === "object") {
+ value = lowerer.classIteratorDrainCall(value, locOf(arg)) ?? value;
+ }
+ if (value.type.kind === "array") {
+ // Argument iteration materializes holes as present undefined
+ // before conversion to the dense checked-dynamic argument pack.
+ value = { kind: "arrayLit", elems: [value], spreads: [0], type: value.type, loc: value.loc };
+ }
+ value = lowerer.coerceInto(arg.expression, value, DYN);
+ if (spreads === 1 && index === argNodes.length - 1) {
+ stmts.push({ kind: "exprStmt", expr: {
+ kind: "libCall", fn: "dyn.packPushSpread", args: [ref(), value, { kind: "strLit", value: arg.expression.getText(), type: STRING, loc }], type: VOID, loc,
+ }, loc });
+ } else push(value, "dyn.packPushSpreadIter");
+ });
+ const args = shapes.map((shape, index): IrExpr => {
+ if (shape.mode === "arguments") return ref();
+ const value: IrExpr = shape.mode === "rest" || shape.mode === "dynRest"
+ ? { kind: "dynInvoke", recv: ref(), method: "slice", calleeName: "arguments.slice", args: [{ kind: "dynFrom", value: { kind: "numLit", value: index, type: F64, loc }, type: DYN, loc }], type: DYN, loc }
+ : { kind: "dynKeyGet", value: ref(), key: { kind: "strLit", value: String(index), type: STRING, loc }, type: DYN, loc };
+ return lowerer.coerceInto(blame, value, shape.type);
+ });
+ // Every supported form has at least one native slot to carry the
+ // evaluation prefix, including calls whose only slot is their rest.
+ args[0] = { kind: "seqExpr", stmts, result: args[0]!, type: args[0]!.type, loc };
+ return args;
+ }
type ArgSource = ts.Expression | { ir: IrExpr };
const isIr = (s: ArgSource | undefined): s is { ir: IrExpr } =>
s !== undefined && !("kind" in s);
@@ -539,9 +587,18 @@ function defaultParameterShape(lowerer: Lowerer, param: ts.ParameterDeclaration,
lowerer.unsupported("SC1090", source, "spread arguments into fixed parameter positions (a spread can only fill a rest parameter)");
}
const slotType = i < restAt ? positional[i]!.type : DYN;
- const value = isIr(source)
- ? lowerer.coerceInto(blame, source.ir, slotType)
- : lowerer.lowerExprExpecting(source, slotType);
+ let value: IrExpr;
+ if (isIr(source)) {
+ let ir = source.ir;
+ // Tuple-spread storage also feeds later arguments. Declare it in
+ // this shared sequence, not inside one argument's initializer,
+ // whose statement frame ends before the next field is read.
+ if (ir.kind === "seqExpr") {
+ stmts.push(...ir.stmts);
+ ir = ir.result;
+ }
+ value = lowerer.coerceInto(blame, ir, slotType);
+ } else value = lowerer.lowerExprExpecting(source, slotType);
const saved = lowerer.declareHiddenLocal("%callArg", slotType);
stmts.push({ kind: "varDecl", localId: saved.id, init: value, loc: value.loc });
return { kind: "varRef", localId: saved.id, type: slotType, loc: value.loc };
@@ -1657,6 +1714,7 @@ export function genericFnOf(lowerer: Lowerer, ident: ts.Identifier): GenericFnIn
fnCtx.generator = generatorMeta(lowerer, inst.returnType);
}
lowerer.fnStack.push(fnCtx);
+ lowerer.localClassInstantiations.push({ owner: decl, name: inst.name });
try {
// STATIC generic methods: `this`/`super` name the RECEIVER class (a
// dynamic value) — the lowerStaticMethod fence, applied here because
@@ -1736,6 +1794,7 @@ export function genericFnOf(lowerer: Lowerer, ident: ts.Identifier): GenericFnIn
lowerer.typeParamTsBindings = prevTsBindings;
lowerer.implicitParamTypes = prevImplicit;
lowerer.instantiationContext = prevContext;
+ lowerer.localClassInstantiations.pop();
lowerer.suppressStats = prevSuppress;
}
}
@@ -5689,6 +5748,7 @@ function lowerOptionalStringNumber(
shapes: readonly ParamShape[],
argNodes: readonly ts.Expression[],
): boolean {
+ if (canCompleteRuntimeSpread(lowerer, shapes)) return false;
const restAt = shapes.findIndex((s) => s.mode === "rest" || s.mode === "dynRest" || s.mode === "islandRest");
let position = 0;
for (const arg of argNodes) {
@@ -5705,6 +5765,11 @@ function lowerOptionalStringNumber(
return false;
}
+ function canCompleteRuntimeSpread(lowerer: Lowerer, shapes: readonly ParamShape[]): boolean {
+ return shapes.length > 0 && shapes.every((shape) => shape.mode !== "islandRest" && shape.callDefault === undefined &&
+ canDynCheckTo(shape.type, (id) => lowerer.shapes.get(id), (id) => lowerer.unions.get(id)));
+ }
+
/** METHOD calls on dyn receivers (`pkg.name.replace(...)`, `rawName.split`,
* `ws.packages.filter(...)` — JSON.parse-derived values): validate the
* receiver's dyn kind, extract, and ride the STATIC method machinery — the
diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts
index 73ef90e4f..15c8f5a8a 100644
--- a/packages/compiler/src/frontend/lowering/lower-classes.ts
+++ b/packages/compiler/src/frontend/lowering/lower-classes.ts
@@ -5,7 +5,7 @@ import { InternalCompilerError } from "../../errors.js";
* accessor access, upcasts, `new` expressions, and the builtin Error
* hierarchy registration. */
import * as ts from "../ts7/adapter.js";
-import type { Lowerer } from "./lowerer.js";
+import type { FnCtx, Lowerer } from "./lowerer.js";
import { BOOL, DATE_T, DYN, F64, bytesOf, IrClassDef, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js";
import { MAX_GENERIC_INSTANCES, appendImplicitUndefinedReturn, bodyReadsArguments, generatorMeta, genericCallInstance, implicitAnyParamSymbolsOf, implicitCallInstance, implicitMonoFile, omittedArgFor, type GenericFnInfo, type ParamShape } from "./lower-calls.js";
import { isGenericCallableMemberType, jsOpenObjectType, typeKey } from "../type-mapper.js";
@@ -28,8 +28,30 @@ import { rejectStaticThis } from "./static-this.js";
import { lowerUrlNew } from "./lower-url.js";
import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js";
+function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
+ if (!ts.isClassExpression(decl)) return false;
+ for (let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
+ if (ts.isFunctionLike(parent)) return true;
+ }
+ return false;
+}
+
+export function storedClassValueType(lowerer: Lowerer, expression: ts.Expression): IrType | null {
+ if (ts.isIdentifier(expression)) {
+ const stored = lowerer.peekLocal(expression) ?? lowerer.globalOf(expression);
+ if (stored?.type.kind === "classval") return stored.type;
+ }
+ return lowerer.mapTypeOf(lowerer.typeOf(expression));
+}
+
export interface ClassInfo {
def: IrClassDef;
+ localClass?: {
+ context: FnCtx | null;
+ bodies: Map;
+ captureSources: string[];
+ ready: boolean;
+ };
/** ALL fields visible on instances — the inherited ones included — for
* receiver-side lookup (def.fields carries the layout order). */
fields: Map;
@@ -1181,6 +1203,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
}
}
+ if (base?.localClass) lowerer.unsupported("SC1090", decl, "extending a function-local class");
const fields = new Map(base ? base.fields : []);
const errorRooted = (() => {
for (let c = base; c; c = c.base) if (c.builtinError) return true;
@@ -1817,7 +1840,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
// name (the override stays on the vtable), a shadowed field, or
// a generic-class instantiation's member.
if (
- implicitMonoFile(decl.getSourceFile()) &&
+ implicitMonoFile(decl.getSourceFile()) && !functionLocalClass(decl) &&
ts.isIdentifier(member.name) &&
!lowerer.virtualJsMethods.has(member) &&
inst === undefined && decl.typeParameters === undefined &&
@@ -2392,6 +2415,9 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
// INSTANTIATION prints its family's name — JS has one `Box`.
jsName: jsNameOverride ?? decl.name?.text ?? "",
...(base ? { base: base.def.name } : {}),
+ jsLength: ctor?.parameters.findIndex((parameter) => parameter.initializer || parameter.dotDotDotToken) === -1
+ ? ctor.parameters.length
+ : Math.max(0, ctor?.parameters.findIndex((parameter) => parameter.initializer || parameter.dotDotDotToken) ?? 0),
// Layout order: the base chain's fields as an IDENTICAL prefix,
// then this class's own — what makes an upcast a reinterpret.
// Redeclared INHERITED fields contribute no slot (their
@@ -3009,6 +3035,9 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
export function classValueRef(lowerer: Lowerer, info: ClassInfo, blame: ts.Node): IrExpr {
const display = info.def.name.replace(/^%|^%m\d+\./, "");
fenceDecorationThrows(lowerer, info, blame);
+ if (info.localClass && !ts.isClassExpression(blame)) {
+ lowerer.unsupported("SC1090", blame, "named self references inside function-local classes");
+ }
if (info.generic) {
// `typeof Box` — the uninstantiated FAMILY as a value: no thunk, no
// single constructor ABI. INSTANTIATIONS have class objects
@@ -3155,19 +3184,12 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
return "";
}
-/** A class EXPRESSION's ClassInfo: collection on first encounter (the
- * declaration path over the shared ClassLikeDeclaration machinery, with
- * NamedEvaluation supplying the runtime .name), idempotent per node —
- * tryLowerExpression's speculative visits and the heritage recursion reuse the
- * first collection. The honest v1 boundary is TOP-LEVEL evaluation
- * positions only: each evaluation of a class expression in JS mints a
- * DISTINCT class (fresh identity, fresh statics), and one immortal
- * class object is exact only for expressions that evaluate exactly
- * once. Statics-bearing expressions additionally restrict to positions
- * where "immediately before the enclosing statement" IS the evaluation
- * point (lowerFileInit drains pendingClassExprInits there). */
+/** Collect a class expression once per enclosing specialization. Function-local
+ * evaluations allocate fresh constructor objects; top-level expressions use
+ * immortal templates. Static initialization remains limited to whole top-level
+ * initializers, where pendingClassExprInits preserves its evaluation point. */
export function lowerClassExpressionInfo(lowerer: Lowerer, expr: ts.ClassExpression): ClassInfo {
- const cached = lowerer.exprClassInfoByNode.get(expr);
+ const cached = lowerer.exprClassInfoByName.get(lowerer.classNamer(expr));
if (cached) return cached;
// Reentrancy guard: heritage resolution can DEMAND another class
// expression's collection (extends through property assignments —
@@ -3183,21 +3205,26 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
"class expressions whose extends chain re-enters their own collection (a cyclic base through property assignments)",
);
}
- if (lowerer.instantiationContext) {
- lowerer.unsupported(
- "SC1090",
- expr,
- "class expressions inside generic functions (each instantiation would need its own class)",
- );
- }
+ let local = false;
for (let p: ts.Node | undefined = expr.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
- if (ts.isFunctionLike(p) || ts.isClassStaticBlockDeclaration(p)) {
+ if (ts.isClassStaticBlockDeclaration(p)) {
lowerer.unsupported(
"SC1090",
expr,
- "class expressions inside functions (each evaluation creates a DISTINCT class in JS — fresh identity, fresh statics; declare the class at top level)",
+ "class expressions inside static blocks",
);
}
+ if (ts.isFunctionLike(p)) local = true;
+ }
+ if (local && expr.members.some((member) => member.name && ts.isPrivateIdentifier(member.name))) {
+ lowerer.unsupported("SC1090", expr, "function-local classes with private brands");
+ }
+ if (local && (expr.heritageClauses?.length || expr.typeParameters?.length ||
+ decoratorNodesOf(expr).length || expr.members.some((member) =>
+ ts.isClassStaticBlockDeclaration(member) || decoratorNodesOf(member).length ||
+ ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) ||
+ (ts.isMethodDeclaration(member) && member.typeParameters?.length)))) {
+ lowerer.unsupported("SC1090", expr, "function-local classes with inheritance, statics, decorators, or generic members");
}
lowerer.collectingExprClasses.add(expr);
try {
@@ -3207,7 +3234,11 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
}
const info = lowerer.classes.get(lowerer.classNamer(expr));
if (!info) throw new PoisonError(); // collection poisoned and reported
- lowerer.exprClassInfoByNode.set(expr, info);
+ if (local) {
+ info.def.localCaptures = [];
+ info.localClass = { context: null, bodies: new Map(), captureSources: [], ready: false };
+ }
+ lowerer.exprClassInfoByName.set(info.def.name, info);
lowerer.exprClasses.push(info);
lowerer.onExprClassCollected?.(info);
// Static field initializers and static blocks run when the class
@@ -3272,7 +3303,26 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
}
}
}
- return classValueRef(lowerer, lowerClassExpressionInfo(lowerer, expr), expr);
+ const info = lowerClassExpressionInfo(lowerer, expr);
+ if (info.localClass && !info.localClass.ready) {
+ const context = newFnCtx(true, null, null, VOID);
+ info.localClass.context = context;
+ lowerer.fnStack.push(context);
+ try {
+ const bodies = lowerClassMembers(lowerer, info);
+ for (const body of bodies) info.localClass.bodies.set(body.name, body);
+ info.def.localCaptures = context.captures!;
+ info.localClass.captureSources = context.captureSources;
+ info.localClass.ready = true;
+ } finally {
+ lowerer.fnStack.pop();
+ info.localClass.context = null;
+ }
+ }
+ const value = classValueRef(lowerer, info, expr);
+ return info.localClass && value.kind === "classRef"
+ ? { ...value, captures: info.localClass.captureSources }
+ : value;
}
/** The EXACT class a receiver expression is statically known to BE (not
@@ -3301,7 +3351,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
}
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
- if (ts.isClassExpression(init)) return lowerer.exprClassInfoByNode.get(init) ?? null;
+ if (ts.isClassExpression(init)) return lowerer.exprClassInfoByName.get(lowerer.classNamer(init)) ?? null;
if (ts.isIdentifier(init)) {
const initSym = lowerer.resolveValueSymbol(init);
const aliased = initSym ? (lowerer.classBySymbol.get(initSym) ?? null) : null;
@@ -3553,7 +3603,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
* value or the member doesn't resolve. */
export function lowerClassValueProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null {
if (expr.questionDotToken) return null;
- const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
+ const recvT = storedClassValueType(lowerer, expr.expression);
if (recvT?.kind !== "classval") return null;
const loc = locOf(expr);
const member = expr.name.text;
@@ -3910,7 +3960,7 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
// demand-driven like generic-fn instances, not reachability units:
// they are never registered as units, so wantBody's name-keyed gate
// cannot apply — every member of a demanded instantiation lowers.
- const always = info.genericInstance !== undefined || info.mixinInstance !== undefined;
+ const always = info.genericInstance !== undefined || info.mixinInstance !== undefined || info.localClass !== undefined;
// A FAMILY has no constructor function at all (nothing constructs it;
// construction resolves to instantiations) and declares no instance
// members — only its statics lower below.
@@ -3951,15 +4001,26 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
* the base's signature — forward every param to super(), then run own
* field initializers. */
export function lowerClassCtor(lowerer: Lowerer, info: ClassInfo): IrFunction {
+ const cached = info.localClass?.bodies.get(`%${info.def.name}.constructor`);
+ if (cached) return cached;
return withInstanceBindings(lowerer, info, () => lowerClassCtorInner(lowerer, info));
}
+ function classCaptureParams(info: ClassInfo, context: FnCtx): Pick {
+ if (!info.localClass) return {};
+ const environment = info.localClass.context;
+ if (!environment) throw new InternalCompilerError("local class lowered outside its lexical environment");
+ return { classCaptures: (context.captures ?? []).map((capture, index) => ({
+ ...capture, slot: environment.captures!.findIndex((entry) => entry.localId === context.captureSources[index]),
+ })) };
+ }
+
function lowerClassCtorInner(lowerer: Lowerer, info: ClassInfo): IrFunction {
const className = info.def.name;
const thisType: IrType = { kind: "object", className };
const prevClass = lowerer.currentClass;
lowerer.currentClass = info;
- lowerer.fnStack.push(newFnCtx(false, null, null, VOID));
+ lowerer.fnStack.push(newFnCtx(info.localClass !== undefined, null, null, VOID));
try {
const thisLocal = lowerer.declareThis(thisType);
const params: IrParam[] = [{ localId: thisLocal.id, name: "this", type: thisType }];
@@ -4028,6 +4089,7 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
params,
returnType: VOID,
locals: lowerer.ctx.locals,
+ ...classCaptureParams(info, lowerer.ctx),
body,
loc: locOf(info.ctor ?? info.decl!),
};
@@ -4285,6 +4347,11 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
* see collectClassShape). */
export function lowerClassMethodMember(lowerer: Lowerer, info: ClassInfo,
fnLike: ts.MethodDeclaration | ts.AccessorDeclaration,): IrFunction | null {
+ if (info.localClass?.ready) {
+ const member = classMemberNameOf(lowerer, fnLike.name);
+ const name = ts.isMethodDeclaration(fnLike) ? member : `${ts.isGetAccessor(fnLike) ? "get" : "set"}:${member}`;
+ return info.localClass.bodies.get(`%${info.def.name}.${name}`) ?? null;
+ }
return withInstanceBindings(lowerer, info, () => lowerClassMethodMemberInner(lowerer, info, fnLike));
}
@@ -4332,7 +4399,7 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
: isAsync && sig.ret.kind === "promise"
? sig.ret.inner
: sig.ret;
- const fnCtx = newFnCtx(false, null, null, bodyReturn);
+ const fnCtx = newFnCtx(info.localClass !== undefined, null, null, bodyReturn);
fnCtx.isAsync = isAsync;
if (genCh !== null) fnCtx.generator = genCh;
lowerer.fnStack.push(fnCtx);
@@ -4358,6 +4425,7 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction
params,
returnType: bodyReturn,
locals: lowerer.ctx.locals,
+ ...classCaptureParams(info, lowerer.ctx),
body,
loc: locOf(fnLike),
};
@@ -4939,6 +5007,7 @@ function fenceDecorationThrows(lowerer: Lowerer, info: ClassInfo, blame: ts.Node
function lowerProgramClassNew(lowerer: Lowerer, expr: ts.NewExpression, declaredInfo: ClassInfo, loc: SrcLoc): IrExpr {
const info = genericNewTarget(lowerer, expr, declaredInfo);
fenceDecorationThrows(lowerer, info, expr);
+ if (info.localClass) lowerer.unsupported("SC1090", expr, "construction through a function-local class's internal name");
lowerer.noteEdge(`%${info.def.name}.constructor`);
// A ctor-less chain into an EventEmitter base inherits `new C()` —
// zero arguments (the options bag fences, like the super() form).
@@ -5808,7 +5877,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// callee (unannotated heterogeneous registries) keeps a pointed
// fence — annotate the slot with the common constructor type.
{
- const calleeT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
+ const calleeT = storedClassValueType(lowerer, expr.expression);
if (calleeT?.kind === "classval") {
let info = lowerer.classes.get(calleeT.className);
if (!info && ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.name)) {
diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts
index 7e61c6496..5dc0b7fef 100644
--- a/packages/compiler/src/frontend/lowering/lower-exprs.ts
+++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts
@@ -26,7 +26,7 @@ import { fenceEnumObjectValue, lowerEnumAccess } from "./lower-enums.js";
import { ambientNsRootOf, ambientUndefReadType, ambientUndefVarRootOf, ambientUndefinedFnSymbolOf, contextualUndefReadType, fenceEarlyAliasUse, fenceEarlyNsMemberRef, lowerNsIdentifierValue, nsMemberIdentOf, nsUndefRead, nsWritableTarget } from "./lower-namespaces.js";
import { expandoMemberRead, expandoWritableTarget } from "./lower-expando.js";
import { lowerSocketInstanceOf, lowerTlsRootCertificates } from "./lower-server.js";
-import { findGenericMethodOn, lowerStaticFieldRead, staticFieldWriteTarget } from "./lower-classes.js";
+import { findGenericMethodOn, lowerStaticFieldRead, staticFieldWriteTarget, storedClassValueType } from "./lower-classes.js";
import { bindingNeverReassigned, funcTypeFromParamShapes, implicitMonoFile, lowerTaggedTemplate, nullishGenericBindingUnitOf, objLitGenericFnInfoOf, objLitGenericFnNodeOf, requireObjLitGenericReceiver } from "./lower-calls.js";
import { mixinFnOfCallee } from "./lower-mixins.js";
import { isConstAssertionTypeNode, isGenericCallableMemberType, isParseArgsDynTypeName, underConstAssertion, unitOnlyUnion } from "../type-mapper.js";
@@ -7989,7 +7989,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
// STANDALONE target class has exactly one possible runtime value
// (itself — no descendants can flow into the slot), so the answer
// folds statically exactly like the named-target folds below.
- const rhsClassval = lowerer.mapTypeOf(lowerer.typeOf(expr.right));
+ const rhsClassval = storedClassValueType(lowerer, expr.right);
if (rhsClassval?.kind === "classval" && !lowerer.caughtLocalOf(expr.left)) {
const targetInfo = lowerer.classes.get(rhsClassval.className);
if (!targetInfo) {
@@ -8013,7 +8013,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
}
const lhsInfo = lowerer.classes.get(left.type.className);
if (!lhsInfo) throw new InternalCompilerError(`lowerer bug: unknown class ${left.type.className}`);
- if (lowerer.inHierarchy(targetInfo) && lowerer.inHierarchy(lhsInfo)) {
+ if ((lowerer.inHierarchy(targetInfo) && lowerer.inHierarchy(lhsInfo)) ||
+ (targetInfo.localClass !== undefined && lhsInfo.localClass !== undefined)) {
const classValue = lowerer.lowerExpr(expr.right);
if (classValue.type.kind !== "classval") lowerer.badType(expr.right, lowerer.typeOf(expr.right));
return { kind: "instanceOfValue", value: left, classValue, type: BOOL, loc };
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 143deca2c..f10611c2d 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -106,6 +106,7 @@ import { FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonIm
import { prepareCjsModuleGraph } from "./lower-node-module.js";
import { ClassInfo, ClassIteratorInfo, GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorConstructorArgs, lowerNew, accessorCall } from "./lower-classes.js";
import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js";
+import { implicitAnyParamSymbolsOf } from "./lower-calls.js";
import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, funcTypeFromParamShapes, implicitMonoFile, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js";
import { lowerArrayMethodCall, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn } from "./lower-containers.js";
import { lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew } from "./containers/bytes.js";
@@ -1201,6 +1202,7 @@ export class Lowerer {
/** Non-null while an instance body lowers: type-parameter symbol →
* concrete IR type, consulted inside mapType's recursion. */
typeParamBindings: Map | null = null;
+ localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = [];
/** The ts-level twin of typeParamBindings, non-null while a CALL-keyed
* instance body lowers: type-parameter symbol → the bound CHECKER type,
* consulted where the mapped IrType has already widened away information
@@ -1936,6 +1938,17 @@ export class Lowerer {
this.mixinTypeContext && this.mixinTypeContext.classNode === decl
? { kind: "object", className: this.mixinTypeContext.className }
: null,
+ localClassInstance: (decl) => {
+ for (let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
+ if (!ts.isFunctionDeclaration(parent) && !ts.isFunctionExpression(parent) &&
+ !ts.isArrowFunction(parent) && !ts.isMethodDeclaration(parent)) continue;
+ if (this.localClassInstantiations.some((entry) => entry.owner === parent)) break;
+ if (parent.typeParameters?.length || (implicitMonoFile(parent.getSourceFile()) && implicitAnyParamSymbolsOf(this, parent))) return null;
+ }
+ const className = this.classNamer(decl);
+ if (!this.collectingExprClasses.has(decl)) this.lowerClassExpressionInfo(decl);
+ return { kind: "object", className };
+ },
mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened),
isStdlibFile: this.isStdlibFile,
isNpmFile: this.isNpmFile,
@@ -2023,10 +2036,18 @@ export class Lowerer {
* builds (no counter can drift between invocations),
* program-unique through the file qualifier, and collision-free with
* user identifiers ('%'). */
- readonly classNamer = (decl: ts.ClassLikeDeclaration): string =>
- ts.isClassExpression(decl)
+ readonly classNamer = (decl: ts.ClassLikeDeclaration): string => {
+ const name = ts.isClassExpression(decl)
? this.qualify(decl.getSourceFile(), `%cx${decl.getStart()}.${decl.name?.text ?? ""}`)
: this.qualify(decl.getSourceFile(), nsPathPrefix(decl) + (decl.name ? decl.name.text : "%anon"));
+ for (let index = this.localClassInstantiations.length - 1; index >= 0; index--) {
+ const entry = this.localClassInstantiations[index]!;
+ for (let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
+ if (parent === entry.owner) return `${name}%${entry.name}`;
+ }
+ }
+ return name;
+ };
/** Follows import aliases to the original declaration's symbol. Every
* value reference resolves through here, so it doubles as the flush
@@ -3660,7 +3681,7 @@ export class Lowerer {
if (typeof catchId === "string") referenced.add(catchId);
// Closure captures name their source locals as PLAIN STRINGS
// (captures: string[]) — a captured broken-class local is live.
- if (rec["kind"] === "closure" && Array.isArray(rec["captures"])) {
+ if ((rec["kind"] === "closure" || rec["kind"] === "classRef") && Array.isArray(rec["captures"])) {
for (const c of rec["captures"]) if (typeof c === "string") referenced.add(c);
}
for (const key of Object.keys(rec)) scan(rec[key]);
@@ -3683,7 +3704,7 @@ export class Lowerer {
for (const fn of brokenLocalFns) {
// Params and captures list their locals by id too — never prune
// those out from under them.
- const referenced = referencedIn([fn.body, fn.params, fn.captures ?? []]);
+ const referenced = referencedIn([fn.body, fn.params, fn.captures ?? [], fn.classCaptures ?? []]);
fn.locals = fn.locals.filter(
(l) => referenced.has(l.id) || !this.typeNamesUnregisteredClass(l.type),
);
@@ -8588,7 +8609,7 @@ export class Lowerer {
* members ride fp.classDecls; expressions register only when their
* containing statement lowers). */
readonly exprClasses: ClassInfo[] = [];
- readonly exprClassInfoByNode = new Map();
+ readonly exprClassInfoByName = new Map();
/** Class expressions whose collection is IN FLIGHT — the reentrancy
* guard for heritage-demanded collection (lowerClassExpressionInfo). */
readonly collectingExprClasses = new Set();
diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts
index 16b69cdda..5c12899ff 100644
--- a/packages/compiler/src/frontend/type-mapper.ts
+++ b/packages/compiler/src/frontend/type-mapper.ts
@@ -794,6 +794,7 @@ export interface TypeMapperCtx {
* inside members, self-referential member types), null outside any
* mixin context (the type alone cannot name a call site). */
mixinClassInstance?: (decl: ts.ClassLikeDeclaration) => IrType | null;
+ localClassInstance?: (decl: ts.ClassExpression) => IrType | null;
/** MIXIN instance INTERSECTIONS (`Tagged.C & Derived` — values built
* through a mixin result): resolved by chain structure to the unique
* pinned instantiation they describe; null when ambiguous or when no
@@ -938,13 +939,9 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
}
}
-/** True when a class expression can NEVER register a lowering: one
- * enclosed by a function-like body or a class static block mints a
- * DISTINCT class per evaluation, which lowerClassExpressionInfo always
- * fences. Its instance/static types must stay UNMAPPED — an object type
- * naming a struct that will never be emitted is the invalid-C escape
- * family (every SITE using the value already carries its own fence). */
-function classExprNeverRegisters(decl: ts.ClassLikeDeclaration): boolean {
+/** Local class types need the enclosing specialization's registered layout.
+ * Static-block classes are sent through the same hook for an explicit fence. */
+function classExprNeedsContext(decl: ts.ClassLikeDeclaration): boolean {
if (!ts.isClassExpression(decl)) return false;
for (let p: ts.Node | undefined = decl.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (ts.isFunctionLike(p) || ts.isClassStaticBlockDeclaration(p)) return true;
@@ -1420,9 +1417,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return viaMixin;
}
}
- // A class expression inside a function/static block never registers
- // (a distinct class per evaluation): unmappable for the same reason.
- if (classExprNeverRegisters(classDecl)) return null;
+ if (classExprNeedsContext(classDecl)) {
+ const local = ts.isClassExpression(classDecl) ? ctx.localClassInstance?.(classDecl) : null;
+ if (local) contextResolutions++;
+ return local ?? null;
+ }
// A GENERIC class's instance type (`Box`) maps to the concrete
// INSTANTIATION's class (`Box%0`), registered on demand — the Lowerer
// hook owns the instance table (monomorphization by flow).
@@ -1457,7 +1456,14 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return { kind: "classval", className: viaMixin.className };
}
}
- if (classExprNeverRegisters(classDecl)) return null;
+ if (classExprNeedsContext(classDecl)) {
+ const local = ts.isClassExpression(classDecl) ? ctx.localClassInstance?.(classDecl) : null;
+ if (local?.kind === "object") {
+ contextResolutions++;
+ return { kind: "classval", className: local.className };
+ }
+ return null;
+ }
// A GENERIC class's static side: only an INSTANTIATED one maps — an
// instantiation expression's type (`Box` as a value) carries a
// construct signature returning the concrete instance, which maps to
diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts
index 8c9c27c0c..eb4d63acc 100644
--- a/packages/compiler/src/ir/analysis.ts
+++ b/packages/compiler/src/ir/analysis.ts
@@ -80,6 +80,7 @@ export function dynDesc(
return def.arms.map((arm) => dynDesc(arm, recordsById, unionsById)).join(" | ");
}
case "func": return "function";
+ case "classval": return "class constructor";
case "map": return "Map";
case "set": return "Set";
default: {
diff --git a/packages/compiler/src/ir/constant-tables.ts b/packages/compiler/src/ir/constant-tables.ts
index 29c981a36..f7d34623c 100644
--- a/packages/compiler/src/ir/constant-tables.ts
+++ b/packages/compiler/src/ir/constant-tables.ts
@@ -50,8 +50,8 @@ export function findConstantNumericTables(mod: IrModule): ReadonlyMap();
if (fn.async || fn.generator) return ranges;
- const captures = new Set((fn.captures ?? []).map((c) => c.localId));
+ const captures = new Set([...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId));
const eligible = new Set(fn.locals.filter((l) => l.type.kind === "f64" && !l.boxed && !l.tdz && !captures.has(l.id)).map((l) => l.id));
type Facts = Map;
diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts
index 8606fb48e..c65436d4a 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -239,11 +239,9 @@ export type IrType =
| { kind: "object"; className: string } // heap, refcounted class instance
/** The class STATIC side as a value — `typeof C`, the type of the class
* name itself and of `new (…) => T` constructor-typed slots. Runtime
- * representation is the per-class IMMORTAL class object (ScrClassObj:
- * preorder interval, construct thunk, .name string) emitted once per
- * classRef-referenced class — so identity `===` is one pointer compare
- * and retains/releases are no-ops on the immortal (the regex-literal
- * discipline; isRefCounted says true for container/RC uniformity).
+ * representation is ScrClassObj: an immortal top-level template or a fresh
+ * function-local object owning captured binding boxes. Identity is one
+ * pointer comparison; local instances retain their constructor object.
* Values of `classval:C` are C's class object or a STRICT DESCENDANT's —
* the object kind's nominal, upcast-only story — and every legal flow
* preserves the constructor ABI (upcast requires the descendant's
@@ -251,7 +249,8 @@ export type IrType =
* completion against C's one signature sound. Allowed in locals,
* globals, params, returns, class/record fields, capture boxes, array
* elements, Map VALUES, and union arms; fenced out of Map keys, Set
- * elements, JSON, dyn/jsval conversion, and ToString. */
+ * elements, JSON, jsval conversion, and ToString. Unknown slots retain
+ * class identity and allow an exact typed constructor round trip. */
| { kind: "classval"; className: string }
/** An ECMAScript module namespace object for one statically-known module.
* `moduleId` is a canonical compiled source-file or builtin identity.
@@ -1117,6 +1116,10 @@ export interface IrLibIdentity {
export interface IrClassDef {
name: string;
+ /** Present for a class evaluated inside a function. Each evaluation owns
+ * fresh identity and these shared binding boxes; instance layouts carry a
+ * private class-object pointer before their source fields. */
+ localCaptures?: IrParam[];
/** The JS-observable `.name` of the class (the runtime class object's
* name string, and what `C.name` folds to). Differs from `name` because
* IR names are program-qualified (`%m1.C`, `%cx…` for class
@@ -1124,6 +1127,8 @@ export interface IrClassDef {
* binding name for `const x = class {}`, or "" for truly anonymous
* expressions. Absent on the runtime-provided defs (never valuable). */
jsName?: string;
+ /** JavaScript constructor arity, ending before the first default or rest parameter. */
+ jsLength?: number;
/** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC
* helpers, and vtable live in the runtime (ScrError / scr_error_*), so
* backends emit no definitions for it — only the preorder-interval
@@ -1340,6 +1345,10 @@ export interface IrFunction {
* variables received through the closure environment, in caps[] order.
* Each is also listed in `locals` (with boxed: true); it is NOT a param. */
captures?: IrParam[];
+ /** Instance methods/constructors of local classes borrow captures through
+ * their first (`this`) parameter. The slot indexes the class object's
+ * capture array; no closure parameter is added to the method ABI. */
+ classCaptures?: (IrParam & { slot: number })[];
/** Async: the body runs on a fiber; `returnType` is the INNER type T (a
* `return v` fulfills with v) while ordinary call sites receive
* Promise. With `generator` present, call sites receive the lazy
@@ -4744,8 +4753,7 @@ export type IrExpr =
/** Numeric operands; comparisons yield bool. `===`/`!==` additionally
* accept two same-typed arrays: reference identity (pointer compare),
* matching JS object equality — and two same-typed CLASS VALUES, where
- * the pointer compare IS class identity (one immortal object per
- * class). */
+ * the pointer compare IS class identity. */
| { kind: "bin"; op: IrNumBinOp; left: IrExpr; right: IrExpr; type: IrType; loc: SrcLoc }
/** `~` is JS bitwise NOT: ToInt32 the operand, complement, back to f64. */
| { kind: "unary"; op: "-" | "!" | "~"; operand: IrExpr; type: IrType; loc: SrcLoc }
@@ -5140,14 +5148,13 @@ export type IrExpr =
* with the new object as arg 0 (retained — the ctor owns and releases its
* `this` param like any callee). Result is owned (+1). */
| { kind: "new"; className: string; args: IrExpr[]; type: IrType; loc: SrcLoc }
- /** The class itself as a value: a pointer to `className`'s immortal
- * class object (type `classval:className`, +1 — a no-op retain on the
- * immortal, kept for the uniform owned-temp discipline, the regexLit
- * pattern). The frontend notes an edge to `%className.constructor` at
+ /** The class itself as an owned value. `captures` creates fresh identity
+ * and retains the named boxes; absent captures use an immortal template.
+ * The frontend notes an edge to `%className.constructor` at
* every classRef, so a value's construct thunk always has a constructor
* to call; backends emit class objects (and thunks) for exactly the
* classes some classRef in the module names. */
- | { kind: "classRef"; className: string; type: IrType; loc: SrcLoc }
+ | { kind: "classRef"; className: string; captures?: string[]; type: IrType; loc: SrcLoc }
/** `new X(args)` through a class VALUE: call the class object's
* construct thunk. `callee` is classval-typed; args are completed
* against `%.constructor`'s ABI — sound because every
@@ -6208,6 +6215,7 @@ export function canConvertToDyn(
// listener boundary (a mustCall-wrapped handler receiving the payload).
if (t.kind === "object" && t.className === "%Error") return true;
if (isDynTypedRefType(t)) return true;
+ if (t.kind === "classval") return true;
if (t.kind === "func") return canBoxFuncIntoDyn(t, getRecord, getUnion);
if (DYN_HANDLE_KINDS.has(t.kind)) return true;
// Promises box by REFERENCE (SCR_DYN_PROMISE): promise carries its
@@ -6231,7 +6239,7 @@ export function canConvertToDyn(
// boundary exactly like a bare func dynFrom).
return !!def && def.arms.every((a) =>
a.kind === "undefinedT" || isJsonSafeType(a, getRecord, getUnion) ||
- isDynTypedRefType(a) || DYN_HANDLE_KINDS.has(a.kind) ||
+ isDynTypedRefType(a) || a.kind === "classval" || DYN_HANDLE_KINDS.has(a.kind) ||
(a.kind === "func" && canBoxFuncIntoDyn(a, getRecord, getUnion)) ||
(a.kind === "promise" && canConvertToDyn(a, getRecord, getUnion)),
);
@@ -6258,6 +6266,7 @@ function canBoxDynComposite(
case "dyn":
case "undefinedT":
case "nullT":
+ case "classval":
return true;
case "bytes":
return t.elem === "u8";
@@ -6304,6 +6313,7 @@ export function canDynCheckTo(
// unknown record fields with the undefined value.
if (isJsonSafeAt(t, getRecord, getUnion, false, false, new Set(), true)) return true;
if (t.kind === "bytes" && t.elem === "u8") return true;
+ if (t.kind === "classval") return true;
if (t.kind === "object" && t.className === "%Error") return true;
if (t.kind === "func") return canAdaptDynFuncTo(t, getRecord, getUnion);
if (DYN_HANDLE_KINDS.has(t.kind)) return true;
diff --git a/packages/compiler/src/ir/scalar-records.ts b/packages/compiler/src/ir/scalar-records.ts
index 62fcdae1e..df3630ffd 100644
--- a/packages/compiler/src/ir/scalar-records.ts
+++ b/packages/compiler/src/ir/scalar-records.ts
@@ -14,7 +14,7 @@ function scalarTemporaries(value: IrExpr): boolean {
}
function producer(fn: IrFunction, shapes: ReadonlyMap): Producer | null {
- if (fn.async || fn.generator || fn.captures?.length || fn.returnType.kind !== "record") return null;
+ if (fn.async || fn.generator || fn.captures?.length || fn.classCaptures?.length || fn.returnType.kind !== "record") return null;
const shape = shapes.get(fn.returnType.shapeId);
if (!shape || shape.tuple || shape.indexValue || shape.fields.length === 0 || shape.fields.length > MAX_FIELDS ||
shape.fields.some((f) => f.type.kind !== "f64")) return null;
@@ -58,8 +58,8 @@ function fieldOnlyUses(fn: IrFunction, localId: string, shape: IrRecordShape): b
case "varRef": case "incDec": case "assignExpr":
if (node.localId === localId) return false;
break;
- case "closure":
- if (node.captures.includes(localId)) return false;
+ case "closure": case "classRef":
+ if (node.captures?.includes(localId)) return false;
break;
}
return everyExprChild(node, expr, stmt);
diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts
index 9b423b1d1..e4aa2b870 100644
--- a/packages/compiler/src/ir/validate.test.ts
+++ b/packages/compiler/src/ir/validate.test.ts
@@ -5,6 +5,40 @@ import { validateModule } from "./validate.js";
const loc = { file: "numeric-read.ts", start: 0, end: 0 };
+function localClassModule(): IrModule {
+ const self: IrType = { kind: "object", className: "Local" };
+ const mod = expressionModule({ kind: "classRef", className: "Local", captures: ["outer"], type: { kind: "classval", className: "Local" }, loc }, []);
+ mod.classes = [{ name: "Local", jsName: "Local", fields: [], localCaptures: [{ localId: "shared", name: "value", type: F64 }], loc }];
+ mod.functions[0]!.locals = [{ id: "outer", name: "value", type: F64, mutable: true, boxed: true }];
+ mod.functions.push({
+ name: "%Local.constructor", params: [{ localId: "self", name: "this", type: self }],
+ locals: [{ id: "self", name: "this", type: self, mutable: false }, { id: "capture", name: "value", type: F64, mutable: true, boxed: true }],
+ classCaptures: [{ localId: "capture", name: "value", type: F64, slot: 0 }],
+ returnType: VOID, body: [], loc,
+ });
+ return mod;
+}
+
+test("local classes retain serialized capture slots and fresh identity", () => {
+ const mod = localClassModule();
+ expect(validateModule(mod)).toEqual([]);
+ expect(deserializeModule(serializeModule(mod))).toEqual(mod);
+});
+
+test.each(["missing", "unboxed", "type", "slot", "receiver", "closure", "layout", "direct-new"])("local classes reject an invalid %s environment", (variant) => {
+ const mod = localClassModule();
+ const ctor = mod.functions[1]!;
+ if (variant === "missing") mod.functions[0]!.locals = [];
+ if (variant === "unboxed") delete mod.functions[0]!.locals[0]!.boxed;
+ if (variant === "type") ctor.locals[1]!.type = STRING;
+ if (variant === "slot") ctor.classCaptures![0]!.slot = 1;
+ if (variant === "receiver") ctor.params[0]!.type = F64;
+ if (variant === "closure") ctor.captures = [];
+ if (variant === "layout") mod.classes![0]!.runtime = true;
+ if (variant === "direct-new") mod.functions[0]!.body = [{ kind: "exprStmt", expr: { kind: "new", className: "Local", args: [], type: { kind: "object", className: "Local" }, loc }, loc }];
+ expect(validateModule(mod).length).toBeGreaterThan(0);
+});
+
test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as IrType])("nullable %j payloads preserve an explicit absence tag", (type) => {
const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [
{ id: "nullable", arms: [type, NULL_T, UNDEFINED_T] },
diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index 42f140bb9..36cf2f731 100644
--- a/packages/compiler/src/ir/validate.ts
+++ b/packages/compiler/src/ir/validate.ts
@@ -1624,6 +1624,12 @@ export function validateModule(mod: IrModule): IrValidationError[] {
errors.push({ message: `duplicate class "${cls.name}"`, loc: cls.loc });
}
classesByName.set(cls.name, cls);
+ if (cls.localCaptures !== undefined && (cls.runtime || cls.base !== undefined || cls.genericOf !== undefined)) {
+ errors.push({ message: `class ${cls.name}: local class cannot use a runtime or inherited layout`, loc: cls.loc });
+ }
+ if (cls.jsLength !== undefined && (!Number.isSafeInteger(cls.jsLength) || cls.jsLength < 0)) {
+ errors.push({ message: `class ${cls.name}: invalid constructor length`, loc: cls.loc });
+ }
const seen = new Set();
for (const f of cls.fields) {
if (seen.has(f.name)) {
@@ -1664,6 +1670,9 @@ export function validateModule(mod: IrModule): IrValidationError[] {
errors.push({ message: `class ${cls.name}: undeclared base "${cls.base}"`, loc: cls.loc });
continue;
}
+ if (base.localCaptures !== undefined) {
+ errors.push({ message: `class ${cls.name}: cannot extend a local class`, loc: cls.loc });
+ }
const seen = new Set([cls.name]);
for (let c: IrClassDef | undefined = base; c; c = c.base !== undefined ? classesByName.get(c.base) : undefined) {
if (seen.has(c.name)) {
@@ -2065,12 +2074,26 @@ function validateFunction(
}
if (p.type.kind === "caught") err(`param "${p.name}" is caught-typed`, fn.loc);
}
- for (const c of fn.captures ?? []) {
+ for (const c of [...(fn.captures ?? []), ...(fn.classCaptures ?? [])]) {
const local = locals.get(c.localId);
if (!local) err(`capture "${c.name}" has no local entry "${c.localId}"`, fn.loc);
else if (!local.boxed) err(`capture local "${c.localId}" is not boxed`, fn.loc);
+ else if (!typeEquals(local.type, c.type)) err(`capture local "${c.localId}" has the wrong type`, fn.loc);
if (c.type.kind === "caught") err(`capture "${c.name}" is caught-typed`, fn.loc);
}
+ if (fn.classCaptures !== undefined) {
+ const self = fn.params[0];
+ const cls = self?.type.kind === "object" ? classes.get(self.type.className) : undefined;
+ if (fn.captures !== undefined || cls?.localCaptures === undefined) {
+ err("class captures require a local-class instance receiver and no closure environment", fn.loc);
+ }
+ for (const capture of fn.classCaptures) {
+ const slot = cls?.localCaptures?.[capture.slot];
+ if (!Number.isInteger(capture.slot) || !slot || !typeEquals(slot.type, capture.type)) {
+ err(`class capture "${capture.name}" has an invalid slot or type`, fn.loc);
+ }
+ }
+ }
const expectType = (expr: IrExpr, want: IrType, what: string) => {
if (!typeEquals(expr.type, want)) {
@@ -3241,6 +3264,7 @@ function validateFunction(
break;
}
const ctor = functions.get(`%${e.className}.constructor`);
+ if (cls.localCaptures !== undefined) err(`new ${e.className}: local class requires a class value`, e.loc);
if (!ctor) {
err(`new ${e.className}: missing constructor function`, e.loc);
break;
@@ -3349,6 +3373,17 @@ function validateFunction(
if (!typeEquals(e.type, { kind: "classval", className: e.className })) {
err(`classRef to "${e.className}" must have that classval type`, e.loc);
}
+ if ((e.captures === undefined) !== (cls.localCaptures === undefined) ||
+ (e.captures?.length ?? 0) !== (cls.localCaptures?.length ?? 0)) {
+ err(`classRef to "${e.className}" must supply its class captures`, e.loc);
+ }
+ (e.captures ?? []).forEach((id, index) => {
+ const local = locals.get(id);
+ const wanted = cls.localCaptures?.[index];
+ if (!local?.boxed || (wanted && !typeEquals(local.type, wanted.type))) {
+ err(`classRef capture "${id}" must name a box with the declared type`, e.loc);
+ }
+ });
break;
}
case "newValue": {
@@ -3389,6 +3424,8 @@ function validateFunction(
// Both sides must be hierarchy members: the operand needs a vt
// word to read; a standalone target class has one possible value
// and the frontend folds it statically.
+ if (classes.get(e.classValue.type.className)?.localCaptures !== undefined &&
+ e.value.type.kind === "object" && classes.get(e.value.type.className)?.localCaptures !== undefined) break;
if (!hierarchy.has(e.classValue.type.className)) {
err(`instanceOfValue against standalone class "${e.classValue.type.className}"`, e.loc);
}
diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts
index d713d49cd..ed567e60f 100644
--- a/packages/compiler/test/native-codegen-integration.test.ts
+++ b/packages/compiler/test/native-codegen-integration.test.ts
@@ -166,7 +166,7 @@ describe.runIf(supported)("LLVM native helper integration", () => {
expect(await symbols(helperObject, ["-gU"]))
.toEqual(await symbols(clangObject, ["-gU"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
- expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v3");
+ expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v4");
const clangExe = join(dir, "clang-program");
const linkDriver = join(dir, "clang-driver.c");
@@ -460,7 +460,7 @@ describe.runIf(supported)("LLVM native helper integration", () => {
.stdout.trim().split("\n");
expect(undefinedSymbols).toContain("_sf_scale");
expect(undefinedSymbols).toContain("_sf_callback_mix");
- expect(undefinedSymbols).toContain("_scr_runtime_abi_v3");
+ expect(undefinedSymbols).toContain("_scr_runtime_abi_v4");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json
index 862de8b4a..1e5750176 100644
--- a/packages/compiler/test/ts7/baselines/order-parity.json
+++ b/packages/compiler/test/ts7/baselines/order-parity.json
@@ -11103,6 +11103,48 @@
"/tests/diagnostics/intl-segmenter.ts"
],
"diags": []
+ },
+ "/tests/corpus/local-class-call-spreads.js": {
+ "order": [
+ "/tests/corpus/local-class-call-spreads.js"
+ ],
+ "diags": []
+ },
+ "/tests/corpus/local-class-evaluation.ts": {
+ "order": [
+ "/tests/corpus/local-class-evaluation.ts"
+ ],
+ "diags": []
+ },
+ "/tests/corpus/local-class-lifetimes.ts": {
+ "order": [
+ "/tests/corpus/local-class-lifetimes.ts"
+ ],
+ "diags": []
+ },
+ "/tests/corpus/local-class-specialization.js": {
+ "order": [
+ "/tests/corpus/local-class-specialization.js"
+ ],
+ "diags": []
+ },
+ "/tests/corpus/local-class-unknown.ts": {
+ "order": [
+ "/tests/corpus/local-class-unknown.ts"
+ ],
+ "diags": []
+ },
+ "/tests/corpus/local-class-values.ts": {
+ "order": [
+ "/tests/corpus/local-class-values.ts"
+ ],
+ "diags": []
+ },
+ "/tests/diagnostics/local-class-boundaries.ts": {
+ "order": [
+ "/tests/diagnostics/local-class-boundaries.ts"
+ ],
+ "diags": []
}
}
}
diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs
index c209e8202..33cd428e2 100644
--- a/packages/runtime-darwin-arm64/scripts/build.mjs
+++ b/packages/runtime-darwin-arm64/scripts/build.mjs
@@ -180,7 +180,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: RUNTIME_PACK_MATRIX.target,
- runtime_abi: { version: 3, marker: "scr_runtime_abi_v3" },
+ runtime_abi: { version: 4, marker: "scr_runtime_abi_v4" },
compiler: {
command: compiler,
identity: compilerVersion,
diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs
index a646767ee..7f03bfd85 100644
--- a/packages/runtime-pack-common/scripts/build.mjs
+++ b/packages/runtime-pack-common/scripts/build.mjs
@@ -121,7 +121,7 @@ async function build() {
const licensed = [[join(runtimeRoot, "LICENSE"), "artifacts/licenses/scriptc-runtime.txt", "Apache-2.0"], [join(quickjs, "LICENSE"), "artifacts/licenses/quickjs-ng.txt", "MIT"], [join(vendorRoot, "ryu", "LICENSE-Boost"), "artifacts/licenses/ryu.txt", "BSL-1.0"], [join(zlib, "LICENSE"), "artifacts/licenses/zlib.txt", "Zlib"], [join(mbedtls, "LICENSE"), "artifacts/licenses/mbedtls.txt", "Apache-2.0"]];
licensed.push([join(vendorRoot, "unicode", "LICENSE"), "artifacts/licenses/unicode.txt", "Unicode-3.0"]);
await Promise.all(licensed.map(async ([source, destination]) => { const output = join(buildRoot, destination); await mkdir(dirname(output), { recursive: true }); await copyFile(source, output); }));
- const manifest = { schema: "scriptc.runtime-pack.v1", format: 1, package: packageManifest.name, version: packageManifest.version, target: matrix.target, runtime_abi: { version: 3, marker: "scr_runtime_abi_v3" }, compiler: { command: compiler, identity: compilerVersion, target: matrix.target.llvm_triple }, macros: { executable: ["SCR_DYNAMIC", "SCR_TEXT_DECODER_LEGACY"], excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required" }, flavors, archives: archives.map((entry) => ({ ...entry, predicate: archiveSpecs.get(entry.id).predicate })), system_libraries: matrix.system_libraries, licenses: licensed.map(([, path, license]) => ({ path, license })) };
+ const manifest = { schema: "scriptc.runtime-pack.v1", format: 1, package: packageManifest.name, version: packageManifest.version, target: matrix.target, runtime_abi: { version: 4, marker: "scr_runtime_abi_v4" }, compiler: { command: compiler, identity: compilerVersion, target: matrix.target.llvm_triple }, macros: { executable: ["SCR_DYNAMIC", "SCR_TEXT_DECODER_LEGACY"], excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required" }, flavors, archives: archives.map((entry) => ({ ...entry, predicate: archiveSpecs.get(entry.id).predicate })), system_libraries: matrix.system_libraries, licenses: licensed.map(([, path, license]) => ({ path, license })) };
await writeFile(stagedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const suffix = `${process.pid}-${Math.random().toString(36).slice(2)}`;
await installRuntimePack({ outputRoot, manifestPath, stagedOutputRoot, stagedManifestPath, backupRoot: join(packageRoot, `.runtime-pack-artifacts-backup-${suffix}`), backupManifestPath: join(packageRoot, `.runtime-pack-manifest-backup-${suffix}`) });
diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c
index a7f4f0736..139a95d1b 100644
--- a/packages/runtime/src/scr_assert.c
+++ b/packages/runtime/src/scr_assert.c
@@ -451,7 +451,7 @@ static bool scr_assert_dyn_same_value(const ScrDyn *a, const ScrDyn *b) {
return a->v.str->len == b->v.str->len &&
memcmp(a->v.str->data, b->v.str->data, a->v.str->len) == 0;
case SCR_DYN_FUNC:
- return a == b || a->v.fn.clo == b->v.fn.clo;
+ return scr_dyn_strict_eq(a, b);
case SCR_DYN_HANDLE:
/* Identity is the HANDLE (the strict_eq stance). */
return a->v.handle.tag == b->v.handle.tag && a->v.handle.ptr == b->v.handle.ptr;
@@ -518,7 +518,7 @@ static bool scr_assert_dyn_deep_eq(const ScrDyn *a, const ScrDyn *b) {
return a->v.str->len == b->v.str->len &&
memcmp(a->v.str->data, b->v.str->data, a->v.str->len) == 0;
case SCR_DYN_FUNC:
- return a->v.fn.clo == b->v.fn.clo;
+ return scr_dyn_strict_eq(a, b);
case SCR_DYN_HANDLE:
/* Node's deepStrictEqual over two distinct live handles walks own
* enumerable props we do not model; same-handle is the only case a
diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c
index 418e5b54d..5de7a7b23 100644
--- a/packages/runtime/src/scr_async_dyn.c
+++ b/packages/runtime/src/scr_async_dyn.c
@@ -198,7 +198,7 @@ static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) {
for (size_t i = 0; i < *n; i++) {
ScrDyn *l = list[i].fn;
bool same = l == fn || (l->kind == SCR_DYN_FUNC && fn->kind == SCR_DYN_FUNC &&
- l->v.fn.clo == fn->v.fn.clo);
+ scr_dyn_strict_eq(l, fn));
if (same) {
scr_dyn_release(l);
memmove(list + i, list + i + 1, (*n - i - 1) * sizeof *list);
@@ -471,7 +471,7 @@ void scr_process_off_warning(ScrDyn *fn) {
for (size_t i = 0; i < scr_nwarn; i++) {
ScrDyn *l = scr_warn_listeners[i];
bool same = l == fn || (l->kind == SCR_DYN_FUNC && fn->kind == SCR_DYN_FUNC &&
- l->v.fn.clo == fn->v.fn.clo);
+ scr_dyn_strict_eq(l, fn));
if (same) {
scr_dyn_release(l);
memmove(scr_warn_listeners + i, scr_warn_listeners + i + 1,
diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c
index c793f3adc..0a618a21d 100644
--- a/packages/runtime/src/scr_console.c
+++ b/packages/runtime/src/scr_console.c
@@ -105,7 +105,7 @@ static void scr_enable_utf8_console_output(void) {
}
#endif
-void scr_runtime_abi_v3(void) {}
+void scr_runtime_abi_v4(void) {}
void scr_init(void) {
#ifdef _WIN32
diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c
index 24b772c95..657cff2a5 100644
--- a/packages/runtime/src/scr_json.c
+++ b/packages/runtime/src/scr_json.c
@@ -23,6 +23,15 @@
*/
#include "scr_runtime.h"
+/* Constructor capsules expose identity and name/length, but do not model
+ * per-class static property tables yet. Never report a fabricated empty view. */
+static bool scr_dyn_class_reflection_fence(const ScrDyn *value) {
+ if (!value || value->kind != SCR_DYN_FUNC || !value->v.fn.class_obj) return false;
+ static const char message[] = "scriptc: class reflection through unknown is not supported";
+ scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
+ return true;
+}
+
#include
#include
#include
@@ -852,6 +861,7 @@ bool scr_dyn_proxy_has(const ScrDyn *proxy, const ScrStr *key) {
}
ScrDyn *scr_dyn_own_descriptor(const ScrDyn *value, const ScrStr *key) {
+ if (scr_dyn_class_reflection_fence(value)) return NULL;
if (value->kind == SCR_DYN_OBJ) {
ScrDynEntry *entry = scr_dyn_entry((ScrDyn *)value, key);
if (!entry) return scr_dyn_retain(scr_dyn_undefined());
@@ -1204,9 +1214,48 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con
d->v.fn.sig = sig;
d->v.fn.name = name;
d->v.fn.arity = arity;
+ d->v.fn.class_obj = NULL;
return d;
}
+/* A class is function-shaped for typeof and identity, but calling it without
+ * new always throws. The closure owns the class object and its lexical boxes. */
+static ScrDyn *scr_dyn_class_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) {
+ (void)args;
+ (void)argc;
+ ScrClassObj *cls = scr_box_get_ref(closure->caps[0]);
+ ScrJsonBuf message;
+ scr_jb_init(&message);
+ scr_jb_puts(&message, "Class constructor ");
+ scr_jb_write(&message, cls->name->data, cls->name->len);
+ scr_jb_puts(&message, " cannot be invoked without 'new'");
+ scr_classobj_release(cls);
+ scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message));
+ return NULL;
+}
+
+ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key) {
+ ScrClosure *closure = scr_closure_new(NULL, 1);
+ closure->caps[0] = scr_box_new_obj(scr_classobj_retain_v, scr_classobj_release_v, scr_classobj_trace_v);
+ scr_box_set_ref(closure->caps[0], scr_classobj_retain(cls));
+ ScrDyn *value = scr_dyn_new_func(closure, scr_dyn_class_call, (uint32_t)cls->length, type_key, cls->name->data);
+ value->v.fn.class_obj = cls; /* borrowed from the closure */
+ return value;
+}
+
+bool scr_dyn_class_is(const ScrDyn *value, const char *type_key) {
+ return value && value->kind == SCR_DYN_FUNC && value->v.fn.class_obj &&
+ strcmp(value->v.fn.sig, type_key) == 0;
+}
+
+ScrClassObj *scr_dyn_class_check(const ScrDyn *value, const char *type_key, const ScrDynPath *path) {
+ if (!scr_dyn_class_is(value, type_key)) {
+ scr_dyn_check_fail(path, "class constructor", value);
+ return NULL;
+ }
+ return scr_classobj_retain(value->v.fn.class_obj);
+}
+
/* Calling a dyn value: kind check (Node's " is not a function"
* TypeError, catchable), then the boxed thunk — per-argument validation
* into the closure's declared parameter types lives THERE (the thunk is
@@ -2687,6 +2736,7 @@ static const char *scr_dyn_kind_name(const ScrDyn *d);
* valid dense index, every other kind false (tsc admits `in` only on
* object-typed operands). Proxy traps may throw. Borrows both. */
bool scr_dyn_has_key(const ScrDyn *v, const ScrStr *key) {
+ if (scr_dyn_class_reflection_fence(v)) return false;
if (v->kind == SCR_DYN_PROXY) return scr_dyn_proxy_has(v, key);
if (v->kind == SCR_DYN_JSVAL) return scr_dyn_isl_fence(v, "'in'");
if (v->kind == SCR_DYN_TYPED_REF) {
@@ -2712,6 +2762,7 @@ bool scr_dyn_has_key(const ScrDyn *v, const ScrStr *key) {
}
void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) {
+ if (scr_dyn_class_reflection_fence(recv)) return;
if (recv->kind == SCR_DYN_PROXY) { scr_dyn_proxy_set(recv, key, value); return; }
if (recv->kind == SCR_DYN_TYPED_REF) {
ScrDyn *materialized = scr_dyn_typed_ref_materialize(recv);
@@ -3580,6 +3631,7 @@ static void scr_json_delete_member(ScrDyn *object, const ScrStr *key) {
* arrays, typed references and handles keep their explicit boundary;
* deleting from a materialized snapshot would lose the mutation. */
void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) {
+ if (scr_dyn_class_reflection_fence(recv)) return;
if (recv->kind == SCR_DYN_PROXY) { scr_dyn_proxy_delete(recv, key); return; }
if (recv->kind == SCR_DYN_OBJ) {
ScrDynEntry *entry = scr_dyn_entry(recv, key);
@@ -3834,6 +3886,7 @@ bool scr_dyn_strict_eq(const ScrDyn *a, const ScrDyn *b) {
/* The ScrDyn box is a boundary artifact — one closure crossing the
* dyn boundary twice is still ONE JS function value, so identity
* lives in the boxed closure, not the box. */
+ if (a->v.fn.class_obj || b->v.fn.class_obj) return a->v.fn.class_obj == b->v.fn.class_obj;
return a == b || a->v.fn.clo == b->v.fn.clo;
case SCR_DYN_BYTES:
return a->v.bytes == b->v.bytes;
@@ -3878,6 +3931,10 @@ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) {
if (key_len == 6 && memcmp(key, "length", 6) == 0) {
return scr_dyn_new_num((double)d->v.fn.arity);
}
+ if (d->v.fn.class_obj) {
+ static const char message[] = "scriptc: class properties through unknown other than name and length are not supported";
+ scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
+ }
return NULL;
}
@@ -4190,6 +4247,7 @@ static bool scr_dyn_objwalk_entry(ScrDyn *out, const ScrDyn *object,
}
static ScrDyn *scr_dyn_objwalk(const ScrDyn *v, ScrObjWalk mode) {
+ if (scr_dyn_class_reflection_fence(v)) return NULL;
if (v->kind == SCR_DYN_PROXY) {
ScrDyn *trap = scr_dyn_proxy_trap(v, "ownKeys");
if (scr_exc_pending()) return NULL;
@@ -4357,6 +4415,7 @@ ScrDyn *scr_dyn_obj_keys(const ScrDyn *v) { return scr_dyn_objwalk(v, SCR_OBJWAL
* neither contributes enumerable keys. Snapshot names without invoking
* getters; the loop rechecks live ownership before visiting each name. */
ScrDyn *scr_dyn_for_in_keys(const ScrDyn *v) {
+ if (scr_dyn_class_reflection_fence(v)) return NULL;
switch (v->kind) {
case SCR_DYN_OBJ:
case SCR_DYN_ARR:
@@ -4442,6 +4501,7 @@ static void scr_dyn_assign_from(ScrDyn *target, const ScrDyn *src) {
* nothing; index-keyed sources (arrays/strings/bytes) copy their index
* keys like Node; the remaining kinds have no own enumerable keys. */
ScrDyn *scr_dyn_assign(ScrDyn *target, const ScrDyn *src) {
+ if (scr_dyn_class_reflection_fence(target) || scr_dyn_class_reflection_fence(src)) return NULL;
if (target->kind == SCR_DYN_UNDEF || target->kind == SCR_DYN_NULL) {
const char *m = "Cannot convert undefined or null to object";
scr_throw_error_msg(SCR_ERR_TYPE, m, strlen(m));
diff --git a/packages/runtime/src/scr_object.c b/packages/runtime/src/scr_object.c
index df0af0a18..58a85250e 100644
--- a/packages/runtime/src/scr_object.c
+++ b/packages/runtime/src/scr_object.c
@@ -1,10 +1,44 @@
-/* Class instances are per-class C structs emitted by the compiler; the
- * runtime only provides the RC-audit hooks their emitted new/release
- * helpers call — plus the class-OBJECT entry points (classes as values:
- * every class object is an emitted immortal static, so the adapters are
- * no-ops behind the uniform container RC machinery). */
+/* Class instance layouts are emitted by the compiler. Class objects share
+ * one runtime layout, including captured bindings for local classes. */
#include "scr_runtime.h"
+void scr_classobj_trace_v(void *object, ScrTraceVisit visit, void *ctx) {
+ ScrClassObj *c = object;
+ for (size_t i = 0; i < c->ncaps; i++) visit(c->caps[i], ctx);
+}
+
+static void scr_classobj_gcfree(void *object) {
+ scr_obj_free_note();
+ scr_cyc_free(object);
+}
+
+ScrClassObj *scr_classobj_new(const ScrClassObj *template, size_t ncaps) {
+ if (ncaps > (SIZE_MAX - sizeof(ScrClassObj)) / sizeof(ScrBox *))
+ scr_trap("scriptc: class capture allocation overflow\n");
+ ScrClassObj *c = scr_cyc_alloc(sizeof(ScrClassObj) + ncaps * sizeof(ScrBox *),
+ &scr_classobj_trace_v, &scr_classobj_gcfree);
+ c->rc = 1;
+ c->pre = template->pre;
+ c->post = template->post;
+ c->ctor = template->ctor;
+ c->name = template->name;
+ c->ncaps = ncaps;
+ c->length = template->length;
+ scr_obj_alloc_note();
+ return c;
+}
+
+void scr_classobj_release(ScrClassObj *c) {
+ if (!c || c->rc == SIZE_MAX) return;
+ if (--c->rc == 0) {
+ scr_cyc_on_dead(c);
+ for (size_t i = 0; i < c->ncaps; i++) scr_box_release(c->caps[i]);
+ scr_classobj_gcfree(c);
+ } else {
+ scr_cyc_on_release(c);
+ }
+}
+
void *scr_classobj_retain_v(void *c) {
return scr_classobj_retain((ScrClassObj *)c);
}
diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h
index 3344e50dc..104f6afb9 100644
--- a/packages/runtime/src/scr_runtime.h
+++ b/packages/runtime/src/scr_runtime.h
@@ -62,7 +62,7 @@ void scr_init(void);
/* Program objects emitted by the bundled LLVM helper reference this symbol.
* Its versioned spelling makes a mismatched manual runtime link fail before
* the program can start. */
-void scr_runtime_abi_v3(void);
+void scr_runtime_abi_v4(void);
/* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ──────
* Every unrecoverable runtime trap — OOM, semantic range traps, internal-
@@ -451,34 +451,32 @@ int scr_str_cmp(ScrStr *a, ScrStr *b);
int scr_str_cmp_u16(ScrStr *a, ScrStr *b);
/* ── class objects (classes as first-class values) ────────────────────
- * The class STATIC side as a runtime value: one emitted IMMORTAL static
- * per class the program takes as a value (`const X = C`, class
- * expressions, constructor-typed slots). One struct type covers every
- * class — the fields are class-independent — so containers and casts
- * never need per-class knowledge. `pre`/`post` are the SAME preorder
- * numbering the vtables carry (compile-time constants in the emitted
- * initializer), so `x instanceof X` through a value is the usual O(1)
- * interval check with the interval loaded from the class object. `ctor`
- * is the emitted construct thunk — the class's own completed-constructor
- * ABI returning `void *` (the compiler's flow rules guarantee every value
- * in a slot shares one ABI); `name` is the JS-observable `.name` string
- * (an interned immortal literal). rc is always SIZE_MAX: retain/release
- * are no-ops (the regex-literal discipline), the object holds no
- * references, and it can never be part of a cycle (trace = NULL). */
+ * Top-level classes use immortal templates. Evaluating a function-local
+ * class creates a fresh, traced object that owns its captured binding boxes.
+ * Every construct thunk takes the borrowed class object before source
+ * arguments. Instances of a local class retain that object for identity and
+ * method captures. `pre`/`post` still describe the compiled class hierarchy;
+ * local-class instanceof additionally compares the fresh object identity. */
typedef struct ScrClassObj {
- size_t rc; /* SIZE_MAX — every class object is an immortal static */
+ size_t rc;
size_t pre, post;
void *ctor;
const ScrStr *name;
+ size_t ncaps;
+ size_t length;
+ struct ScrBox *caps[];
} ScrClassObj;
static inline ScrClassObj *scr_classobj_retain(ScrClassObj *c) {
- if (c->rc != SIZE_MAX) c->rc++;
+ if (c && c->rc != SIZE_MAX) {
+ c->rc++;
+ scr_cyc_mark_live(c);
+ }
return c;
}
-static inline void scr_classobj_release(ScrClassObj *c) {
- (void)c; /* immortal (NULL-tolerant like every release) */
-}
+void scr_classobj_release(ScrClassObj *c);
+ScrClassObj *scr_classobj_new(const ScrClassObj *template, size_t ncaps);
+void scr_classobj_trace_v(void *c, ScrTraceVisit visit, void *ctx);
/* void*-signature RC adapters (container slots) — scr_object.c. */
void *scr_classobj_retain_v(void *c);
void scr_classobj_release_v(void *c);
@@ -3462,7 +3460,7 @@ struct ScrDyn {
* has no trace header): trial deletion treats it as an external root,
* so nothing dangles — a cycle THROUGH a dyn-boxed function is merely
* never collected (documented divergence). */
- struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; } fn;
+ struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; } fn;
/* SCR_DYN_HANDLE: the retained native handle + its type tag. The
* dyn→handle edge is NOT visible to the cycle collector (the dyn→
* closure stance): handles drop their listener lists at settlement,
@@ -3761,6 +3759,8 @@ ScrDyn *scr_dyn_define_props(ScrDyn *target, ScrDyn *descs);
* (callers retain first when they keep their own reference); `sig`/`name`
* must be static literals (the box never frees them; name may be NULL). */
ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, const char *sig, const char *name);
+ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key);
+bool scr_dyn_class_is(const ScrDyn *value, const char *type_key);
/* Calls a dyn value: a non-function kind throws the catchable TypeError
* " is not a function" (Node's wording — `what` is the call site's
* callee spelling) and returns NULL; a function kind delegates to the
@@ -3783,6 +3783,7 @@ typedef struct ScrDynPath {
const char *key;
size_t index;
} ScrDynPath;
+ScrClassObj *scr_dyn_class_check(const ScrDyn *value, const char *type_key, const ScrDynPath *path);
/* The dynCheck failure path: builds "TypeError: expected at ,
* got " (got == NULL renders as "undefined" — a missing object
diff --git a/packages/runtime/test/test_json.c b/packages/runtime/test/test_json.c
index 5e57fa0bb..5f6aca861 100644
--- a/packages/runtime/test/test_json.c
+++ b/packages/runtime/test/test_json.c
@@ -334,7 +334,39 @@ static void json_callback_tests(void) {
scr_dyn_release(callback);
}
+static void test_class_values(void) {
+ ScrStr *name = S("Stored");
+ ScrClassObj template = { .rc = SIZE_MAX, .name = name, .length = 2 };
+ ScrClassObj *first = scr_classobj_new(&template, 1);
+ first->caps[0] = scr_box_new(SCR_BOX_F64);
+ scr_box_set_f64(first->caps[0], 42);
+ ScrClassObj *second = scr_classobj_new(&template, 0);
+ ScrDyn *a = scr_dyn_new_class(first, "classval:Stored");
+ ScrDyn *again = scr_dyn_new_class(first, "classval:Stored");
+ ScrDyn *b = scr_dyn_new_class(second, "classval:Stored");
+ scr_classobj_release(first);
+ scr_classobj_release(second);
+ check(scr_dyn_strict_eq(a, again) && !scr_dyn_strict_eq(a, b), "boxed class identity belongs to evaluation");
+ ScrClassObj *restored = scr_dyn_class_check(a, "classval:Stored", NULL);
+ check(restored && scr_box_get_f64(restored->caps[0]) == 42, "boxed class retains captured environment");
+ scr_classobj_release(restored);
+ check(!scr_dyn_class_check(a, "classval:Other", NULL) && scr_exc_pending(), "class casts check native constructor type");
+ scr_exc_clear();
+ ScrDyn *arity = scr_dyn_fn_get(a, "length", 6);
+ check(arity && arity->kind == SCR_DYN_NUM && arity->v.num == 2, "boxed constructor length");
+ scr_dyn_release(arity);
+ check(!scr_dyn_call(a, NULL, 0, "Stored") && scr_exc_pending(), "class call requires new");
+ scr_exc_clear();
+ check(!scr_dyn_obj_keys(a) && scr_exc_pending(), "class reflection refuses an incomplete property view");
+ scr_exc_clear();
+ scr_dyn_release(a);
+ scr_dyn_release(again);
+ scr_dyn_release(b);
+ scr_str_release(name);
+}
+
int main(void) {
+ test_class_values();
scr_init();
/* ── primitives ─────────────────────────────────────────────────── */
diff --git a/tests/corpus/local-class-call-spreads.js b/tests/corpus/local-class-call-spreads.js
new file mode 100644
index 000000000..e9d147684
--- /dev/null
+++ b/tests/corpus/local-class-call-spreads.js
@@ -0,0 +1,37 @@
+let trace = [];
+function value(label, result) { trace.push(label); return result; }
+class Color {
+ static rgb(r, g, b, a = value("default", 255)) { return `${r},${g},${b},${a}`; }
+}
+const rgb = [10, 20, 30];
+console.log(Color.rgb(...rgb));
+console.log(Color.rgb(...value("spread", [1, 2]), value("last", 3), value("alpha", 4)));
+console.log(Color.rgb(value("first", 5), ...value("middle", [6]), ...value("end", [7])));
+console.log(trace.join("|"));
+
+function collect(first, second = 9, ...rest) { return `${first}:${second}:${rest.join("-")}`; }
+console.log(collect(...[1, 2, 3, 4]));
+console.log(collect(...[5]));
+console.log(collect(...[], ...[6, undefined, 8]));
+function count(first) { return `${first}:${arguments.length}:${arguments[2]}`; }
+console.log(count(...[10, 20, 30]));
+console.log(count(...rgb));
+const sparse = [, 42, ,];
+console.log(collect(...sparse));
+const grown = [1];
+grown.length = 3;
+grown[2] = 3;
+console.log(collect(...grown));
+function fixed(first) { return first; }
+console.log(fixed(...value("surplus", [11, 12, 13]), value("discard", 14)));
+console.log(trace.join("|"));
+
+function build(seed) {
+ return class Stored {
+ constructor(a, b = 2) { this.value = seed + a + b; }
+ read() { return this.value; }
+ };
+}
+const Stored = build(10);
+const instance = new Stored(...[3]);
+console.log(instance.read());
diff --git a/tests/corpus/local-class-evaluation.ts b/tests/corpus/local-class-evaluation.ts
new file mode 100644
index 000000000..0472b3bb0
--- /dev/null
+++ b/tests/corpus/local-class-evaluation.ts
@@ -0,0 +1,32 @@
+function make(value: number) {
+ return () => class Local {
+ read(): number { return value; }
+ async delayed(): Promise { await Promise.resolve(); return ++value; }
+ };
+}
+const evaluate = make(2);
+const First = evaluate();
+const Second = evaluate();
+const first = new First();
+const second = new Second();
+console.log(First === Second, await first.delayed(), second.read());
+
+function loop(): number[] {
+ const reads: (() => number)[] = [];
+ for (let i = 0; i < 3; i++) {
+ const Local = class { read(): number { return i; } };
+ const value = new Local();
+ reads.push(() => value.read());
+ }
+ return reads.map((read) => read());
+}
+console.log(loop().join(","));
+
+function tdz(): void {
+ const Local = class { read(): number { return later; } };
+ const value = new Local();
+ try { console.log(value.read()); } catch (error) { console.log((error as Error).name); }
+ let later = 17;
+ console.log(value.read());
+}
+tdz();
diff --git a/tests/corpus/local-class-lifetimes.ts b/tests/corpus/local-class-lifetimes.ts
new file mode 100644
index 000000000..548354fb7
--- /dev/null
+++ b/tests/corpus/local-class-lifetimes.ts
@@ -0,0 +1,53 @@
+function factory(initial: number, label: string) {
+ let shared = initial;
+ const Local = class Counter {
+ own: number;
+ constructor(offset: number = shared) {
+ this.own = offset;
+ if (offset < 0) throw new Error(label);
+ }
+ get value(): number { return shared + this.own; }
+ set value(next: number) { shared = next - this.own; }
+ callback = (): string => `${label}:${shared}:${this.own}`;
+ next(): number { return ++shared; }
+ };
+ shared++;
+ return Local;
+}
+const A = factory(2, "first");
+const B = factory(8, "second");
+const a = new A();
+const b = new A(10);
+const c = new B();
+console.log(a.value, b.value, c.value);
+a.value = 20;
+console.log(a.next(), b.value, c.value, a.callback());
+try { new A(-1); } catch (error) { console.log((error as Error).message); }
+console.log(b.callback());
+
+function empty() { return class {}; }
+const Empty = empty();
+const Other = empty();
+const e = new Empty();
+console.log(Empty === Other, e instanceof Empty, e instanceof Other);
+
+function late() {
+ const Local = class { read(): number { return value; } };
+ let value = 13;
+ return Local;
+}
+const Late = late();
+console.log(new Late().read());
+
+function cycle(value: number): number {
+ let read = (): number => value;
+ const Local = class {
+ read(): number { return read(); }
+ direct(): number { return value; }
+ };
+ const instance = new Local();
+ read = (): number => instance.direct();
+ return instance.read();
+}
+for (let i = 0; i < 300; i++) cycle(i);
+console.log(cycle(42));
diff --git a/tests/corpus/local-class-specialization.js b/tests/corpus/local-class-specialization.js
new file mode 100644
index 000000000..d698af55c
--- /dev/null
+++ b/tests/corpus/local-class-specialization.js
@@ -0,0 +1,16 @@
+function make(value) {
+ return class Stored {
+ read() { return value; }
+ matches(other) { return other === value; }
+ };
+}
+const Numbers = make(12);
+const OtherNumbers = make(25);
+const Strings = make("hello");
+const a = new Numbers();
+const b = new OtherNumbers();
+const c = new Strings();
+console.log(Numbers.name, Numbers === OtherNumbers);
+console.log(a.read(), b.read(), c.read());
+console.log(a.matches(12), b.matches(12), c.matches("hello"));
+console.log(a instanceof Numbers, a instanceof OtherNumbers, c instanceof Strings);
diff --git a/tests/corpus/local-class-unknown.ts b/tests/corpus/local-class-unknown.ts
new file mode 100644
index 000000000..080bcf3ba
--- /dev/null
+++ b/tests/corpus/local-class-unknown.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+
+function make(value: number) {
+ return class Stored {
+ constructor(extra: number = 0) { value += extra; }
+ read(): number { return value; }
+ };
+}
+const A = make(12);
+const B = make(25);
+const one: unknown = A;
+const again: unknown = A;
+const two: unknown = B;
+console.log(typeof one, one === again, one === two);
+assert.strictEqual(one, again);
+assert.notStrictEqual(one, two);
+assert.deepStrictEqual(one, again);
+const properties = one as Record;
+console.log(properties.name, properties.length);
+const Restored = one as typeof A;
+console.log(new Restored(2).read(), new B().read());
+const call = one as () => void;
+try { call(); } catch (error) { console.log((error as Error).name, (error as Error).message); }
+class Top { constructor(first: number, second = 0) {} }
+const top: unknown = Top;
+const topProperties = top as Record;
+console.log(topProperties.name, topProperties.length);
+const Saved = top as typeof Top;
+const saved = new Saved(1);
+console.log(saved instanceof Top);
+const list: unknown = [A, B];
+console.log(JSON.stringify(list));
diff --git a/tests/corpus/local-class-values.ts b/tests/corpus/local-class-values.ts
new file mode 100644
index 000000000..7910938c5
--- /dev/null
+++ b/tests/corpus/local-class-values.ts
@@ -0,0 +1,18 @@
+function makeCounter(start: number) {
+ let value = start;
+ const Local = class Counter {
+ read(): number { return value; }
+ increment(): number { return ++value; }
+ callback = (): number => value;
+ };
+ value++;
+ return Local;
+}
+const First = makeCounter(10);
+const Second = makeCounter(20);
+const a = new First();
+const b = new First();
+const c = new Second();
+console.log(First.name, First === First, First === Second);
+console.log(a.read(), b.increment(), a.callback(), c.read());
+console.log(a instanceof First, a instanceof Second, c instanceof First, c instanceof Second);
diff --git a/tests/diagnostics/class-values.ts b/tests/diagnostics/class-values.ts
index 58ec50fc4..d2de5d0f3 100644
--- a/tests/diagnostics/class-values.ts
+++ b/tests/diagnostics/class-values.ts
@@ -47,9 +47,9 @@ someClass.tag = "write";
// runtime class decides which declaration answers.
console.log(someClass.tag);
-// Class expressions inside functions mint a DISTINCT class per evaluation.
+// Local classes with statics still need per-evaluation static storage.
function make(): unknown {
- return class {};
+ return class { static value = 1; };
}
make();
diff --git a/tests/diagnostics/local-class-boundaries.ts b/tests/diagnostics/local-class-boundaries.ts
new file mode 100644
index 000000000..5b509931d
--- /dev/null
+++ b/tests/diagnostics/local-class-boundaries.ts
@@ -0,0 +1,14 @@
+function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; }
+function selfName() { return class Local { copy() { return new Local(); } }; }
+class Base {}
+function derived() { return class extends Base {}; }
+function generic() { return class { read(value: T): T { return value; } }; }
+privateBrand();
+selfName();
+derived();
+generic();
+function genericFactory(value: T) {
+ return class Stored { read(): T { return value; } };
+}
+genericFactory(7);
+// End of unsupported local-class forms.
diff --git a/tests/harness/__snapshots__/class-values.ts.txt b/tests/harness/__snapshots__/class-values.ts.txt
index 16de8b3c1..f00d88efa 100644
--- a/tests/harness/__snapshots__/class-values.ts.txt
+++ b/tests/harness/__snapshots__/class-values.ts.txt
@@ -58,11 +58,11 @@ class-values.ts:48:13 - error SC1090: reading the static member 'tag' through a
| ^~~~~~~~~~~~~
49 |
-class-values.ts:52:10 - error SC1090: class expressions inside functions (each evaluation creates a DISTINCT class in JS — fresh identity, fresh statics; declare the class at top level) are not supported yet
+class-values.ts:52:10 - error SC1090: function-local classes with inheritance, statics, decorators, or generic members are not supported yet
51 | function make(): unknown {
- 52 | return class {};
- | ^~~~~~~~
+ 52 | return class { static value = 1; };
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~~~
53 | }
class-values.ts:59:18 - error SC1090: class expressions with static initializers or static blocks outside a whole-initializer position (their declaration-time code must run exactly where the expression evaluates — bind the class in its own top-level `const C = class …` statement) is not supported yet
diff --git a/tests/harness/__snapshots__/local-class-boundaries.ts.txt b/tests/harness/__snapshots__/local-class-boundaries.ts.txt
new file mode 100644
index 000000000..a9a2c68b2
--- /dev/null
+++ b/tests/harness/__snapshots__/local-class-boundaries.ts.txt
@@ -0,0 +1,33 @@
+local-class-boundaries.ts:1:34 - error SC1090: function-local classes with private brands are not supported yet
+
+ 1 | function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; }
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ 2 | function selfName() { return class Local { copy() { return new Local(); } }; }
+
+local-class-boundaries.ts:2:60 - error SC1090: construction through a function-local class's internal name is not supported yet
+
+ 1 | function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; }
+ 2 | function selfName() { return class Local { copy() { return new Local(); } }; }
+ | ^~~~~~~~~~~
+ 3 | class Base {}
+
+local-class-boundaries.ts:4:29 - error SC1090: function-local classes with inheritance, statics, decorators, or generic members are not supported yet
+
+ 3 | class Base {}
+ 4 | function derived() { return class extends Base {}; }
+ | ^~~~~~~~~~~~~~~~~~~~~
+ 5 | function generic() { return class { read(value: T): T { return value; } }; }
+
+local-class-boundaries.ts:5:29 - error SC1090: function-local classes with inheritance, statics, decorators, or generic members are not supported yet
+
+ 4 | function derived() { return class extends Base {}; }
+ 5 | function generic() { return class { read(value: T): T { return value; } }; }
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ 6 | privateBrand();
+
+local-class-boundaries.ts:13:1 - error SC2001: values of type 'typeof Stored' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown')
+
+ 12 | }
+ 13 | genericFactory(7);
+ | ^~~~~~~~~~~~~~~~~~~~~~~~~
+ 14 | // End of unsupported local-class forms.
\ No newline at end of file